diff --git a/Week09/LinLin/.gitignore b/Week09/LinLin/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/Week09/LinLin/.gitignore @@ -0,0 +1,15 @@ +*.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 diff --git a/Week09/LinLin/app/.gitignore b/Week09/LinLin/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/Week09/LinLin/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Week09/LinLin/app/build.gradle.kts b/Week09/LinLin/app/build.gradle.kts new file mode 100644 index 0000000..fad401e --- /dev/null +++ b/Week09/LinLin/app/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.week09" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.week09" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + 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 + } +} + +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) +} \ No newline at end of file diff --git a/Week09/LinLin/app/proguard-rules.pro b/Week09/LinLin/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Week09/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/Week09/LinLin/app/src/androidTest/java/com/example/week09/ExampleInstrumentedTest.kt b/Week09/LinLin/app/src/androidTest/java/com/example/week09/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..80a673d --- /dev/null +++ b/Week09/LinLin/app/src/androidTest/java/com/example/week09/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.week09 + +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.week09", appContext.packageName) + } +} \ No newline at end of file diff --git a/Week09/LinLin/app/src/main/AndroidManifest.xml b/Week09/LinLin/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1446ba8 --- /dev/null +++ b/Week09/LinLin/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Week09/LinLin/app/src/main/java/com/example/week09/MainActivity.kt b/Week09/LinLin/app/src/main/java/com/example/week09/MainActivity.kt new file mode 100644 index 0000000..6468951 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/MainActivity.kt @@ -0,0 +1,28 @@ +package com.example.week09 + +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.week09.ui.mypage.MyPageScreen +import com.example.week09.ui.theme.Week09Theme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + Week09Theme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + MyPageScreen( + modifier = Modifier.padding(innerPadding), + ) + } + } + } + } +} diff --git a/Week09/LinLin/app/src/main/java/com/example/week09/data/NetworkClient.kt b/Week09/LinLin/app/src/main/java/com/example/week09/data/NetworkClient.kt new file mode 100644 index 0000000..d7fc68f --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/data/NetworkClient.kt @@ -0,0 +1,42 @@ +package com.example.week09.data + +import com.example.week09.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 = HttpLoggingInterceptor.Level.BODY + } + + val authInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("x-api-key", "reqres_3cc917778886402cb49885a9215fb6ff") + .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/Week09/LinLin/app/src/main/java/com/example/week09/data/model/UserData.kt b/Week09/LinLin/app/src/main/java/com/example/week09/data/model/UserData.kt new file mode 100644 index 0000000..00998b2 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/data/model/UserData.kt @@ -0,0 +1,29 @@ +package com.example.week09.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/Week09/LinLin/app/src/main/java/com/example/week09/data/remote/ReqResService.kt b/Week09/LinLin/app/src/main/java/com/example/week09/data/remote/ReqResService.kt new file mode 100644 index 0000000..c61ed3a --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/data/remote/ReqResService.kt @@ -0,0 +1,21 @@ +package com.example.week09.data.remote + +import com.example.week09.data.model.UserListResponse +import com.example.week09.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/Week09/LinLin/app/src/main/java/com/example/week09/data/repository/UserRepository.kt b/Week09/LinLin/app/src/main/java/com/example/week09/data/repository/UserRepository.kt new file mode 100644 index 0000000..da6c8d0 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/data/repository/UserRepository.kt @@ -0,0 +1,27 @@ +package com.example.week09.data.repository + +import com.example.week09.data.NetworkClient +import com.example.week09.data.model.UserData +import com.example.week09.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/Week09/LinLin/app/src/main/java/com/example/week09/ui/mypage/MyPageScreen.kt b/Week09/LinLin/app/src/main/java/com/example/week09/ui/mypage/MyPageScreen.kt new file mode 100644 index 0000000..cb3ffc1 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/ui/mypage/MyPageScreen.kt @@ -0,0 +1,499 @@ +package com.example.week09.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.collectAsState +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.runtime.snapshotFlow +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.week09.R +import com.example.week09.data.model.UserData +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyPageScreen( + modifier: Modifier = Modifier, + viewModel: MyPageViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + var showEditSheet by remember { mutableStateOf(false) } + var selectedFollowingUser by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + val followingList = uiState.followingList + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White), + ) { + if (uiState.isLoading) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + ProfileHeaderSection( + profile = uiState.myProfile, + onEditClick = { showEditSheet = true }, + ) + + ProfileQuickMenuRow() + + SectionDivider() + + NikeMemberBenefitRow() + + SectionDivider() + + FollowingSectionHeader(count = followingList.size) + + if (followingList.isEmpty()) { + Text( + text = uiState.errorMessage ?: "팔로잉 목록이 없습니다.", + 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 + + var currentPage by remember { mutableStateOf(pagerState.currentPage) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .distinctUntilChanged() + .collect { currentPage = it } + } + + 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/Week09/LinLin/app/src/main/java/com/example/week09/ui/mypage/MyPageViewModel.kt b/Week09/LinLin/app/src/main/java/com/example/week09/ui/mypage/MyPageViewModel.kt new file mode 100644 index 0000000..d542645 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/ui/mypage/MyPageViewModel.kt @@ -0,0 +1,68 @@ +package com.example.week09.ui.mypage + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.week09.data.model.UserData +import com.example.week09.data.repository.UserRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MyPageUiState( + val myProfile: UserData? = null, + val followingList: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, +) + +class MyPageViewModel( + private val userRepository: UserRepository = UserRepository(), +) : ViewModel() { + + private val _uiState = MutableStateFlow(MyPageUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val myUserId = 1 + + init { + loadMyPage() + } + + private fun loadMyPage() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + val profileResult = userRepository.getUserProfile(myUserId) + val followingResult = userRepository.getFollowingUsers(page = 1, excludeUserId = myUserId) + + profileResult + .onSuccess { user -> + _uiState.update { it.copy(myProfile = user) } + } + .onFailure { e -> + Log.e(TAG, "Profile load failed", e) + _uiState.update { it.copy(errorMessage = e.message) } + } + + followingResult + .onSuccess { users -> + _uiState.update { it.copy(followingList = users) } + } + .onFailure { e -> + Log.e(TAG, "Following load failed", e) + if (_uiState.value.errorMessage == null) { + _uiState.update { it.copy(errorMessage = e.message) } + } + } + + _uiState.update { it.copy(isLoading = false) } + } + } + + companion object { + private const val TAG = "MyPageViewModel" + } +} diff --git a/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Color.kt b/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Color.kt new file mode 100644 index 0000000..bf89d56 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.week09.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/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Theme.kt b/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Theme.kt new file mode 100644 index 0000000..a1d1146 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.week09.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 Week09Theme( + 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/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Type.kt b/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Type.kt new file mode 100644 index 0000000..019ec45 --- /dev/null +++ b/Week09/LinLin/app/src/main/java/com/example/week09/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.week09.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/Week09/LinLin/app/src/main/res/drawable/ic_chevron_right.xml b/Week09/LinLin/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..9193cb0 --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/LinLin/app/src/main/res/drawable/ic_event.xml b/Week09/LinLin/app/src/main/res/drawable/ic_event.xml new file mode 100644 index 0000000..b1cfa9d --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_event.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/LinLin/app/src/main/res/drawable/ic_launcher_background.xml b/Week09/LinLin/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Week09/LinLin/app/src/main/res/drawable/ic_launcher_foreground.xml b/Week09/LinLin/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Week09/LinLin/app/src/main/res/drawable/ic_order.xml b/Week09/LinLin/app/src/main/res/drawable/ic_order.xml new file mode 100644 index 0000000..e785308 --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_order.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/LinLin/app/src/main/res/drawable/ic_pass.xml b/Week09/LinLin/app/src/main/res/drawable/ic_pass.xml new file mode 100644 index 0000000..3d370fd --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_pass.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/LinLin/app/src/main/res/drawable/ic_settings.xml b/Week09/LinLin/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..7fba4bb --- /dev/null +++ b/Week09/LinLin/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Week09/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Week09/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week09/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Week09/LinLin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Week09/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/Week09/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Week09/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Week09/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Week09/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Week09/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Week09/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Week09/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Week09/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Week09/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Week09/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Week09/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Week09/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/Week09/LinLin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Week09/LinLin/app/src/main/res/values/colors.xml b/Week09/LinLin/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/Week09/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/Week09/LinLin/app/src/main/res/values/strings.xml b/Week09/LinLin/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c51ff40 --- /dev/null +++ b/Week09/LinLin/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Week09 + \ No newline at end of file diff --git a/Week09/LinLin/app/src/main/res/values/themes.xml b/Week09/LinLin/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c37338b --- /dev/null +++ b/Week09/LinLin/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +