diff --git a/.gitignore b/.gitignore index 9dad144..40ea771 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ .externalNativeBuild .cxx local.properties +keystore.properties +*.jks +*.keystore +**/signing/ # Android 하위 모듈 빌드 산출물 **/app/build/ diff --git a/Week10/LinLin/.gitignore b/Week10/LinLin/.gitignore new file mode 100644 index 0000000..d8b31b0 --- /dev/null +++ b/Week10/LinLin/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle +/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 +keystore.properties +*.jks +*.keystore +signing/ diff --git a/Week10/LinLin/CLAUDE.md b/Week10/LinLin/CLAUDE.md new file mode 100644 index 0000000..0ace511 --- /dev/null +++ b/Week10/LinLin/CLAUDE.md @@ -0,0 +1,58 @@ +# 프로젝트 개요 + +UMC 10주차 Nike 스타일 **마이페이지** Android 앱입니다. +9주차에서 구현한 Jetpack Compose UI(프로필, 팔로잉 HorizontalPager, ModalBottomSheet)를 기반으로, 10주차에서는 **Play Store 배포용 AAB 빌드**와 **AI 개발 워크플로우**를 적용합니다. + +# 아키텍처 + +실무형 Clean Architecture의 축소 버전을 따릅니다. + +- **data**: `NetworkClient`, Retrofit `ReqResService`, `UserRepository`, DTO(`UserData`) +- **ui/mypage**: `MyPageViewModel`, `MyPageScreen` 및 하위 Composable +- **ui/theme**: Material3 테마 + +의존성 방향: `ui` → `data` (domain 레이어는 미션 범위에서 생략) + +# 기술 스택 + +- UI: Jetpack Compose (Material3), HorizontalPager, ModalBottomSheet +- 상태 관리: ViewModel + `StateFlow` + `collectAsStateWithLifecycle` +- 네트워크: Retrofit2 + OkHttp3 + Gson (ReqRes API) +- 이미지: Coil Compose +- 비동기: Kotlin Coroutines (`async` 병렬 호출) +- 빌드: AGP 9.x, release AAB 서명 (`keystore.properties`) + +# 코드 컨벤션 + +- ViewModel UI 상태는 **sealed interface**로 관리 (`Loading` / `Success` / `Error`) +- Composable 함수명은 PascalCase, 파일명과 일치 +- Repository는 `suspend` + `Result` (`runCatching`) 패턴 +- API 키·Keystore 정보는 `local.properties` / `keystore.properties`에만 두고 Git에 올리지 않음 +- 릴리즈 빌드에서는 OkHttp 로깅 비활성화 (`BuildConfig.DEBUG` 기준) + +# 빌드 & 배포 + +```bash +# Windows (프로젝트 루트 Week10) +.\gradlew.bat bundleRelease +``` + +AAB 출력 경로: + +``` +app/build/outputs/bundle/release/app-release.aab +``` + +# 주의사항 + +- `*.jks`, `keystore.properties`, `local.properties`, `signing/` 디렉터리는 **절대 커밋하지 말 것** +- Keystore 분실 시 동일 앱 업데이트 불가 — 안전한 로컬/클라우드 백업 필수 +- 미션 브랜치: `feature/week10-{기능명}` 권장 +- Play Store 업로드는 **AAB**만 사용 (APK는 사이드로딩·테스트용) + +# AI 협업 원칙 (Harness Engineering) + +- 불확실하면 추측하지 말고 확인할 것 +- 요청 범위만 최소 변경으로 구현할 것 +- 관련 없는 파일은 수정하지 말 것 +- 새 화면 추가 시 Manifest·네비게이션·ViewModel 상태 패턴을 함께 점검할 것 diff --git a/Week10/LinLin/RELEASE.md b/Week10/LinLin/RELEASE.md new file mode 100644 index 0000000..4ff7226 --- /dev/null +++ b/Week10/LinLin/RELEASE.md @@ -0,0 +1,29 @@ +# 10주차 릴리즈 AAB + +## 빌드 명령 + +```bash +.\gradlew.bat bundleRelease +``` + +## AAB 파일 경로 (PR에 기재) + +``` +app/build/outputs/bundle/release/app-release.aab +``` + +## 서명 설정 + +1. `keystore.properties.example` → `keystore.properties` 복사 후 값 입력 +2. Android Studio: **Build → Generate Signed App Bundle / APK** 로 Keystore 생성 가능 +3. `local.properties`에 `REQRES_API_KEY` 설정 + +## 코드 리뷰 반영 사항 (Week10) + +| 항목 | 개선 내용 | +| --- | --- | +| UiState | `data class` → `sealed interface` (Loading/Success/Error) | +| 생명주기 | `collectAsState` → `collectAsStateWithLifecycle` | +| 네트워크 | 프로필·팔로잉 `async` 병렬 로딩 | +| 보안 | API 키를 `BuildConfig` + `local.properties`로 분리 | +| UX | 에러 화면에 **다시 시도** 버튼 추가 | diff --git a/Week10/LinLin/app/.gitignore b/Week10/LinLin/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/Week10/LinLin/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Week10/LinLin/app/build.gradle.kts b/Week10/LinLin/app/build.gradle.kts new file mode 100644 index 0000000..8442ee0 --- /dev/null +++ b/Week10/LinLin/app/build.gradle.kts @@ -0,0 +1,102 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + load(file.inputStream()) + } +} + +val keystoreProperties = Properties().apply { + val file = rootProject.file("keystore.properties") + if (file.exists()) { + load(file.inputStream()) + } +} + +android { + namespace = "com.example.week10" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.week10" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField( + "String", + "REQRES_API_KEY", + "\"${localProperties.getProperty("REQRES_API_KEY", "")}\"", + ) + } + + signingConfigs { + create("release") { + val storeFilePath = keystoreProperties.getProperty("storeFile") ?: return@create + storeFile = rootProject.file(storeFilePath) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") + 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.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.foundation) + implementation(libs.coil.compose) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.okhttp.logging) + implementation(libs.gson) + implementation(libs.kotlinx.coroutines.android) + 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/Week10/LinLin/app/proguard-rules.pro b/Week10/LinLin/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Week10/LinLin/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/Week10/LinLin/app/src/androidTest/java/com/example/week10/ExampleInstrumentedTest.kt b/Week10/LinLin/app/src/androidTest/java/com/example/week10/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f47a5ca --- /dev/null +++ b/Week10/LinLin/app/src/androidTest/java/com/example/week10/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.week10 + +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.week10", appContext.packageName) + } +} \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/AndroidManifest.xml b/Week10/LinLin/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0a07a9b --- /dev/null +++ b/Week10/LinLin/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/MainActivity.kt b/Week10/LinLin/app/src/main/java/com/example/week10/MainActivity.kt new file mode 100644 index 0000000..efb7333 --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/MainActivity.kt @@ -0,0 +1,28 @@ +package com.example.week10 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.ui.Modifier +import com.example.week10.ui.mypage.MyPageScreen +import com.example.week10.ui.theme.Week10Theme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + Week10Theme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + MyPageScreen( + modifier = Modifier.padding(innerPadding), + ) + } + } + } + } +} diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/data/NetworkClient.kt b/Week10/LinLin/app/src/main/java/com/example/week10/data/NetworkClient.kt new file mode 100644 index 0000000..7a103dc --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/data/NetworkClient.kt @@ -0,0 +1,47 @@ +package com.example.week10.data + +import com.example.week10.BuildConfig +import com.example.week10.data.remote.ReqResService +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object NetworkClient { + + private const val BASE_URL = "https://reqres.in/" + + val reqResService: ReqResService by lazy { + val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + + val authInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("x-api-key", BuildConfig.REQRES_API_KEY) + .build() + chain.proceed(request) + } + + val client = OkHttpClient.Builder() + .addInterceptor(logging) + .addInterceptor(authInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ReqResService::class.java) + } +} diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/data/model/UserData.kt b/Week10/LinLin/app/src/main/java/com/example/week10/data/model/UserData.kt new file mode 100644 index 0000000..11d1d4f --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/data/model/UserData.kt @@ -0,0 +1,29 @@ +package com.example.week10.data.model + +import com.google.gson.annotations.SerializedName + +data class UserData( + val id: Int, + val email: String, + @SerializedName("first_name") + val firstName: String, + @SerializedName("last_name") + val lastName: String, + val avatar: String, +) { + val displayName: String get() = "$firstName $lastName" +} + +data class UserResponse( + val data: UserData, +) + +data class UserListResponse( + val page: Int, + @SerializedName("per_page") + val perPage: Int, + val total: Int, + @SerializedName("total_pages") + val totalPages: Int, + val data: List, +) diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/data/remote/ReqResService.kt b/Week10/LinLin/app/src/main/java/com/example/week10/data/remote/ReqResService.kt new file mode 100644 index 0000000..3fb3613 --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/data/remote/ReqResService.kt @@ -0,0 +1,21 @@ +package com.example.week10.data.remote + +import com.example.week10.data.model.UserListResponse +import com.example.week10.data.model.UserResponse +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface ReqResService { + + @GET("api/users/{id}") + suspend fun getUserProfile( + @Path("id") userId: Int, + ): Response + + @GET("api/users") + suspend fun getUserList( + @Query("page") page: Int = 1, + ): Response +} diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/data/repository/UserRepository.kt b/Week10/LinLin/app/src/main/java/com/example/week10/data/repository/UserRepository.kt new file mode 100644 index 0000000..7b338ce --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/data/repository/UserRepository.kt @@ -0,0 +1,27 @@ +package com.example.week10.data.repository + +import com.example.week10.data.NetworkClient +import com.example.week10.data.model.UserData +import com.example.week10.data.remote.ReqResService + +class UserRepository( + private val service: ReqResService = NetworkClient.reqResService, +) { + + suspend fun getUserProfile(userId: Int): Result = runCatching { + val response = service.getUserProfile(userId) + if (!response.isSuccessful) { + error("Failed to load profile: ${response.code()}") + } + response.body()?.data ?: error("Empty profile response") + } + + suspend fun getFollowingUsers(page: Int = 1, excludeUserId: Int): Result> = + runCatching { + val response = service.getUserList(page) + if (!response.isSuccessful) { + error("Failed to load users: ${response.code()}") + } + response.body()?.data?.filter { it.id != excludeUserId }.orEmpty() + } +} diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/ui/mypage/MyPageScreen.kt b/Week10/LinLin/app/src/main/java/com/example/week10/ui/mypage/MyPageScreen.kt new file mode 100644 index 0000000..bd6aeb2 --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/ui/mypage/MyPageScreen.kt @@ -0,0 +1,515 @@ +package com.example.week10.ui.mypage + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.example.week10.R +import com.example.week10.data.model.UserData +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyPageScreen( + modifier: Modifier = Modifier, + viewModel: MyPageViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showEditSheet by remember { mutableStateOf(false) } + var selectedFollowingUser by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White), + ) { + when (val state = uiState) { + MyPageUiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + is MyPageUiState.Error -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.message, + fontSize = 14.sp, + color = Color(0xFF888888), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = viewModel::retry) { + Text("다시 시도") + } + } + } + + is MyPageUiState.Success -> { + val followingList = state.followingList + + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + ProfileHeaderSection( + profile = state.myProfile, + onEditClick = { showEditSheet = true }, + ) + + ProfileQuickMenuRow() + + SectionDivider() + + NikeMemberBenefitRow() + + SectionDivider() + + FollowingSectionHeader(count = followingList.size) + + if (followingList.isEmpty()) { + Text( + text = "팔로잉 목록이 없습니다.", + fontSize = 14.sp, + color = Color(0xFF888888), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + textAlign = TextAlign.Center, + ) + } else { + FollowingPagerSection( + followingList = followingList, + onUserClick = { selectedFollowingUser = it }, + ) + } + } + + Text( + text = "회원 가입일: 2025년 9월", + fontSize = 12.sp, + color = Color(0xFF888888), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + .padding(vertical = 12.dp), + ) + } + } + } + } + + if (showEditSheet) { + ModalBottomSheet( + onDismissRequest = { showEditSheet = false }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "프로필 수정", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "이번 주차에서는 ModalBottomSheet 연습용 UI입니다.", + fontSize = 14.sp, + color = Color(0xFF888888), + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(24.dp)) + TextButton( + onClick = { + scope.launch { + sheetState.hide() + }.invokeOnCompletion { + if (!sheetState.isVisible) { + showEditSheet = false + } + } + }, + ) { + Text("닫기") + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + selectedFollowingUser?.let { user -> + FollowingImageDialog( + user = user, + onDismiss = { selectedFollowingUser = null }, + ) + } +} + +@Composable +private fun ProfileHeaderSection( + profile: UserData?, + onEditClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 40.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + model = profile?.avatar, + contentDescription = profile?.displayName ?: "프로필 이미지", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(Color(0xFFD9D9D9)), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = profile?.displayName ?: "", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = profile?.email ?: "", + fontSize = 14.sp, + color = Color(0xFF888888), + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = onEditClick, + shape = RoundedCornerShape(50), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 10.dp), + ) { + Text( + text = "프로필 수정", + fontSize = 14.sp, + color = Color.Black, + ) + } + } +} + +@Composable +private fun ProfileQuickMenuRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProfileMenuItem(R.drawable.ic_order, "주문", Modifier.weight(1f)) + VerticalDivider() + ProfileMenuItem(R.drawable.ic_pass, "패스", Modifier.weight(1f)) + VerticalDivider() + ProfileMenuItem(R.drawable.ic_event, "이벤트", Modifier.weight(1f)) + VerticalDivider() + ProfileMenuItem(R.drawable.ic_settings, "설정", Modifier.weight(1f)) + } +} + +@Composable +private fun NikeMemberBenefitRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "나이키 멤버 혜택", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "0개 사용 가능", + fontSize = 13.sp, + color = Color(0xFF888888), + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun FollowingSectionHeader(count: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(start = 20.dp, end = 20.dp, top = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (count > 0) "팔로잉 $count" else "", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.weight(1f), + ) + Text( + text = "편집", + fontSize = 14.sp, + color = Color(0xFF888888), + ) + } +} + +@Composable +private fun FollowingPagerSection( + followingList: List, + onUserClick: (UserData) -> Unit, +) { + val pagerState = rememberPagerState(pageCount = { followingList.size }) + + LaunchedEffect(followingList.size) { + if (pagerState.currentPage >= followingList.size) { + pagerState.scrollToPage(0) + } + } + + FollowingHorizontalPager( + followingList = followingList, + pagerState = pagerState, + onUserClick = onUserClick, + ) + + PagerDotIndicator( + pagerState = pagerState, + pageCount = followingList.size, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) +} + +@Composable +private fun FollowingHorizontalPager( + followingList: List, + pagerState: PagerState, + onUserClick: (UserData) -> Unit, +) { + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 48.dp), + pageSpacing = 16.dp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { page -> + val user = followingList[page] + FollowingUserPage( + user = user, + onClick = { onUserClick(user) }, + ) + } +} + +@Composable +private fun FollowingUserPage( + user: UserData, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + model = user.avatar, + contentDescription = user.displayName, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(Color(0xFFD9D9D9)), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = user.displayName, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "탭하여 크게 보기", + fontSize = 12.sp, + color = Color(0xFF888888), + ) + } +} + +@Composable +private fun PagerDotIndicator( + pagerState: PagerState, + pageCount: Int, + modifier: Modifier = Modifier, +) { + if (pageCount <= 1) return + + val currentPage = pagerState.currentPage + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + ) { + repeat(pageCount) { index -> + val isSelected = currentPage == index + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(if (isSelected) 10.dp else 8.dp) + .clip(CircleShape) + .background(if (isSelected) Color.Black else Color(0xFFCCCCCC)), + ) + } + } +} + +@Composable +private fun FollowingImageDialog( + user: UserData, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = user.displayName) + }, + text = { + AsyncImage( + model = user.avatar, + contentDescription = user.displayName, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFF0F0F0)), + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("닫기") + } + }, + ) +} + +@Composable +private fun ProfileMenuItem( + iconRes: Int, + label: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = label, + tint = Color.Unspecified, + modifier = Modifier.size(28.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF333333), + ) + } +} + +@Composable +private fun VerticalDivider() { + Box( + modifier = Modifier + .width(1.dp) + .height(32.dp) + .background(Color(0xFFEEEEEE)), + ) +} + +@Composable +private fun SectionDivider() { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(Color(0xFFF5F5F5)), + ) +} diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/ui/mypage/MyPageViewModel.kt b/Week10/LinLin/app/src/main/java/com/example/week10/ui/mypage/MyPageViewModel.kt new file mode 100644 index 0000000..0a8f2d8 --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/ui/mypage/MyPageViewModel.kt @@ -0,0 +1,80 @@ +package com.example.week10.ui.mypage + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.week10.data.model.UserData +import com.example.week10.data.repository.UserRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface MyPageUiState { + data object Loading : MyPageUiState + + data class Success( + val myProfile: UserData?, + val followingList: List, + ) : MyPageUiState + + data class Error(val message: String) : MyPageUiState +} + +class MyPageViewModel( + private val userRepository: UserRepository = UserRepository(), +) : ViewModel() { + + private val _uiState = MutableStateFlow(MyPageUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val myUserId = 1 + + init { + loadMyPage() + } + + fun retry() { + loadMyPage() + } + + private fun loadMyPage() { + viewModelScope.launch { + _uiState.value = MyPageUiState.Loading + + runCatching { + coroutineScope { + val profileDeferred = async { userRepository.getUserProfile(myUserId) } + val followingDeferred = async { + userRepository.getFollowingUsers(page = 1, excludeUserId = myUserId) + } + + val profileResult = profileDeferred.await() + val followingResult = followingDeferred.await() + + val profile = profileResult.getOrElse { throw it } + val following = followingResult.getOrElse { throw it } + + profile to following + } + }.onSuccess { (profile, following) -> + _uiState.value = MyPageUiState.Success( + myProfile = profile, + followingList = following, + ) + }.onFailure { e -> + Log.e(TAG, "My page load failed", e) + _uiState.value = MyPageUiState.Error( + message = e.message ?: "데이터를 불러오지 못했습니다.", + ) + } + } + } + + companion object { + private const val TAG = "MyPageViewModel" + } +} diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Color.kt b/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Color.kt new file mode 100644 index 0000000..cdeefc0 --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.week10.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Theme.kt b/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Theme.kt new file mode 100644 index 0000000..640e19b --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.week10.ui.theme + +import android.app.Activity +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 = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun Week10Theme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + 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 + ) +} \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Type.kt b/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Type.kt new file mode 100644 index 0000000..2dea749 --- /dev/null +++ b/Week10/LinLin/app/src/main/java/com/example/week10/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.week10.ui.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 + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_chevron_right.xml b/Week10/LinLin/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..9193cb0 --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_event.xml b/Week10/LinLin/app/src/main/res/drawable/ic_event.xml new file mode 100644 index 0000000..b1cfa9d --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_event.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_launcher_background.xml b/Week10/LinLin/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_launcher_foreground.xml b/Week10/LinLin/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_order.xml b/Week10/LinLin/app/src/main/res/drawable/ic_order.xml new file mode 100644 index 0000000..e785308 --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_order.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_pass.xml b/Week10/LinLin/app/src/main/res/drawable/ic_pass.xml new file mode 100644 index 0000000..3d370fd --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_pass.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week10/LinLin/app/src/main/res/drawable/ic_settings.xml b/Week10/LinLin/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..7fba4bb --- /dev/null +++ b/Week10/LinLin/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week10/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Week10/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Week10/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Week10/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Week10/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Week10/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Week10/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Week10/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Week10/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Week10/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Week10/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Week10/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Week10/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Week10/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Week10/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Week10/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/Week10/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Week10/LinLin/app/src/main/res/values/colors.xml b/Week10/LinLin/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/Week10/LinLin/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/Week10/LinLin/app/src/main/res/values/strings.xml b/Week10/LinLin/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d5d037c --- /dev/null +++ b/Week10/LinLin/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Week10 + \ No newline at end of file diff --git a/Week10/LinLin/app/src/main/res/values/themes.xml b/Week10/LinLin/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2e735de --- /dev/null +++ b/Week10/LinLin/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +