diff --git a/README.md b/README.md index 38969476..614fdce4 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,6 @@ - 힌트 코드를 참고하여 수동 DI를 구현한다 - 실제 서버 데이터가 잘 로드되는지 Log로 확인한다 +## Step2 + +- NEXTSTEP 조직의 저장소 목록을 선형 리스트로 노출한다 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 83925eef..ffc98a59 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,4 +72,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.retrofit2.kotlinx.serialization.converter) + + testImplementation(libs.coroutines.test) + testImplementation(libs.turbine) } diff --git a/app/src/main/java/nextstep/github/ui/MainActivity.kt b/app/src/main/java/nextstep/github/ui/MainActivity.kt index c9d02b2c..ec5d3c2b 100644 --- a/app/src/main/java/nextstep/github/ui/MainActivity.kt +++ b/app/src/main/java/nextstep/github/ui/MainActivity.kt @@ -3,6 +3,7 @@ package nextstep.github.ui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn @@ -17,34 +18,26 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import nextstep.github.NextStepApp import nextstep.github.ui.model.UiGitHubRepoInfo +import nextstep.github.ui.repos.ReposScreen +import nextstep.github.ui.repos.ReposViewModel import nextstep.github.ui.theme.GithubTheme internal class MainActivity : ComponentActivity() { + private val reposViewModel: ReposViewModel by viewModels { ReposViewModel.Factory } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val appContainer = (application as NextStepApp).appContainer - val getGitHubRepositoryUseCase = appContainer.getGitHubRepositoryUseCase - val repos: Flow> = - flow { emit(getGitHubRepositoryUseCase("next-step")) } setContent { - - val repo by repos.collectAsStateWithLifecycle(emptyList()) - GithubTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - LazyColumn { - items(repo) { r -> - Column { - Text(text = r.fullName) - Text(text = r.description) - } - } - } + ReposScreen( + reposViewModel = reposViewModel + ) } } } diff --git a/app/src/main/java/nextstep/github/ui/repos/ReposErrorScreen.kt b/app/src/main/java/nextstep/github/ui/repos/ReposErrorScreen.kt new file mode 100644 index 00000000..eca10384 --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/ReposErrorScreen.kt @@ -0,0 +1,60 @@ +package nextstep.github.ui.repos + +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.height +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nextstep.github.R + +@Composable +internal fun ReposErrorScreen( + onRetryClick: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.repos_errorscreen_guide_message), + style = MaterialTheme.typography.bodyMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onRetryClick + ) { + Text( + text = stringResource(R.string.repos_errorscreen_retry_button), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@Preview +@Composable +private fun ReposErrorScreenPreview() { + MaterialTheme { + ReposErrorScreen( + onRetryClick = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/nextstep/github/ui/repos/ReposLoadingScreen.kt b/app/src/main/java/nextstep/github/ui/repos/ReposLoadingScreen.kt new file mode 100644 index 00000000..69186147 --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/ReposLoadingScreen.kt @@ -0,0 +1,31 @@ +package nextstep.github.ui.repos + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +internal fun ReposLoadingScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Preview +@Composable +private fun ReposLoadingScreenPreview() { + MaterialTheme { + ReposLoadingScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/nextstep/github/ui/repos/ReposScreen.kt b/app/src/main/java/nextstep/github/ui/repos/ReposScreen.kt new file mode 100644 index 00000000..3594479a --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/ReposScreen.kt @@ -0,0 +1,29 @@ +package nextstep.github.ui.repos + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +@Composable +internal fun ReposScreen( + reposViewModel: ReposViewModel, +) { + val uiState by reposViewModel.repos.collectAsStateWithLifecycle() + + ReposScreen( + uiState = uiState, + onRetryClick = reposViewModel::searchRepos + ) +} + +@Composable +private fun ReposScreen( + uiState: ReposUiState, + onRetryClick: () -> Unit, +) { + when (uiState) { + is ReposUiState.Loading -> ReposLoadingScreen() + is ReposUiState.Success -> ReposSuccessScreen(repos = uiState.repos) + is ReposUiState.Error -> ReposErrorScreen(onRetryClick = onRetryClick) + } +} \ No newline at end of file diff --git a/app/src/main/java/nextstep/github/ui/repos/ReposSuccessScreen.kt b/app/src/main/java/nextstep/github/ui/repos/ReposSuccessScreen.kt new file mode 100644 index 00000000..763d750f --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/ReposSuccessScreen.kt @@ -0,0 +1,90 @@ +package nextstep.github.ui.repos + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import nextstep.github.R +import nextstep.github.ui.model.UiGitHubRepoInfo +import nextstep.github.ui.repos.component.GitHubRepoInfoItem + +@Composable +internal fun ReposSuccessScreen( + repos: List, + modifier: Modifier = Modifier +) { + Scaffold( + modifier = modifier, + topBar = { + Box( + modifier = Modifier + .height(56.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.repos_successscreen_title), + style = MaterialTheme.typography.titleLarge + ) + } + }, + content = { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues)) { + items(repos) { r -> + GitHubRepoInfoItem( + gitHubRepoInfo = r, + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } + } + ) +} + +private data class ReposSuccessScreenPreviewParameter( + val repos: List +) + +private class ReposSuccessScreenPreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence = sequenceOf( + ReposSuccessScreenPreviewParameter( + repos = List(10) { + UiGitHubRepoInfo( + fullName = "next-step/nextstep-docs", + description = "NextStep 메뉴얼 및 문서를 관리하는 저장소" + ) + } + ) + ) +} + +@Preview +@Composable +private fun ReposSuccessScreenPreview( + @PreviewParameter(ReposSuccessScreenPreviewParameterProvider::class) + parameter: ReposSuccessScreenPreviewParameter +) { + MaterialTheme { + ReposSuccessScreen(repos = parameter.repos) + } +} + diff --git a/app/src/main/java/nextstep/github/ui/repos/ReposUiState.kt b/app/src/main/java/nextstep/github/ui/repos/ReposUiState.kt new file mode 100644 index 00000000..6161b1e5 --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/ReposUiState.kt @@ -0,0 +1,9 @@ +package nextstep.github.ui.repos + +import nextstep.github.ui.model.UiGitHubRepoInfo + +internal sealed interface ReposUiState { + data object Loading : ReposUiState + data class Success(val repos: List) : ReposUiState + data class Error(val throwable: Throwable) : ReposUiState +} \ No newline at end of file diff --git a/app/src/main/java/nextstep/github/ui/repos/ReposViewModel.kt b/app/src/main/java/nextstep/github/ui/repos/ReposViewModel.kt new file mode 100644 index 00000000..1743300a --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/ReposViewModel.kt @@ -0,0 +1,62 @@ +package nextstep.github.ui.repos + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import nextstep.github.NextStepApp +import nextstep.github.ui.usecase.GetGitHubRepositoryUseCase + + +internal class ReposViewModel( + private val getGitHubRepositoryUseCase: GetGitHubRepositoryUseCase +) : ViewModel() { + + class Query(val organization: String) + // 같은 저장소라도 검색이 가능하도록 String 타입이 아닌 Query 타입을 사용 + private val refreshTrigger = MutableStateFlow(Query(TARGET_ORGANIZATION)) + + @OptIn(ExperimentalCoroutinesApi::class) + val repos: StateFlow = refreshTrigger + .flatMapLatest { query -> + flow { + emit(ReposUiState.Loading) + val repos = getGitHubRepositoryUseCase(organization = query.organization) + emit(ReposUiState.Success(repos)) + }.catch { e -> emit(ReposUiState.Error(e)) } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ReposUiState.Loading + ) + + fun searchRepos(organization: String = TARGET_ORGANIZATION) { + refreshTrigger.update { Query(organization) } + } + + companion object { + private const val TARGET_ORGANIZATION = "next-step" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val getGitHubRepositoryUseCase = (this[APPLICATION_KEY] as NextStepApp) + .appContainer + .getGitHubRepositoryUseCase + + ReposViewModel(getGitHubRepositoryUseCase) + } + } + } +} + diff --git a/app/src/main/java/nextstep/github/ui/repos/component/GitHubRepoInfoItem.kt b/app/src/main/java/nextstep/github/ui/repos/component/GitHubRepoInfoItem.kt new file mode 100644 index 00000000..c81b32c8 --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/repos/component/GitHubRepoInfoItem.kt @@ -0,0 +1,44 @@ +package nextstep.github.ui.repos.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import nextstep.github.ui.model.UiGitHubRepoInfo + +@Composable +internal fun GitHubRepoInfoItem( + gitHubRepoInfo: UiGitHubRepoInfo, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(color = MaterialTheme.colorScheme.surface) + .padding(16.dp) + ) { + Text( + text = gitHubRepoInfo.fullName, + style = MaterialTheme.typography.titleLarge + ) + Text( + text = gitHubRepoInfo.description, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview +@Composable +private fun GitHubRepoInfoItemPreview() { + GitHubRepoInfoItem( + gitHubRepoInfo = UiGitHubRepoInfo( + fullName = "next-step/nextstep-docs", + description = "NextStep 메뉴얼 및 문서를 관리하는 저장소" + ) + ) +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6c1a9..c83c7e3b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ GitHub + 레포지토리를 불러오는 과정에서 문제가 발생했습니다 + 다시 시도하기 + NEXTSTEP Repositories diff --git a/app/src/test/java/nextstep/github/.gitkeep b/app/src/test/java/nextstep/github/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/test/kotlin/nextstep/github/ui/repos/ReposViewModelTest.kt b/app/src/test/kotlin/nextstep/github/ui/repos/ReposViewModelTest.kt new file mode 100644 index 00000000..610aaedb --- /dev/null +++ b/app/src/test/kotlin/nextstep/github/ui/repos/ReposViewModelTest.kt @@ -0,0 +1,76 @@ +package nextstep.github.ui.repos + +import app.cash.turbine.test +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import nextstep.github.ui.model.UiGitHubRepoInfo +import nextstep.github.ui.usecase.GetGitHubRepositoryUseCase +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ReposViewModelTest { + private lateinit var scheduler: TestCoroutineScheduler + + @Before + fun setUp() { + // given: 테스트에 사용할 스케줄러와 디스패처 설정 + scheduler = TestCoroutineScheduler() + val testDispatcher = StandardTestDispatcher(scheduler) + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + // after: 메인 디스패처를 원래 상태로 복원 + Dispatchers.resetMain() + } + + @Test + fun `레포를 가져올 때 에러가 발생하면 uiState는 ReposUiState Error이다`() = runTest { + // given: 레포지토리를 가져오는 유즈케이스가 예외를 발생시킴 + val getGitHubRepositoryUseCase = object : GetGitHubRepositoryUseCase { + override suspend fun invoke(organization: String): List { + throw Exception() + } + } + + // when: 에러가 발생하는 상황에서 ViewModel이 생성됨 + val reposViewModel = ReposViewModel(getGitHubRepositoryUseCase) + + // then: 상태 변화가 Loading -> Error로 진행되는지 확인 + reposViewModel.repos.test { + val initial = awaitItem() + val latest = awaitItem() + assert(initial is ReposUiState.Loading) + assert(latest is ReposUiState.Error) + } + } + + @Test + fun `레포를 가져올 때 리스트가 제대로 들어있다면 uiState는 ReposUiState Success이다`() = runTest { + // given: 레포지토리를 성공적으로 가져오는 유즈케이스가 빈 리스트를 반환함 + val getGitHubRepositoryUseCase = object : GetGitHubRepositoryUseCase { + override suspend fun invoke(organization: String): List { + return emptyList() + } + } + + // when: 성공적인 상황에서 ViewModel이 생성됨 + val reposViewModel = ReposViewModel(getGitHubRepositoryUseCase) + + // then: 상태 변화가 Loading -> Success로 진행되는지 확인 + reposViewModel.repos.test { + val initial = awaitItem() + val latest = awaitItem() + assert(initial is ReposUiState.Loading) + assert(latest is ReposUiState.Success) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77ca290d..f886e1a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,12 @@ kotlinxSerializationJson = "1.6.3" # retrofit2 retrofit2KotlinxSerializationConverter = "1.0.0" +# coroutines +coroutine = "1.9.0" + +# turbine +turbine = "1.1.0" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -39,6 +45,12 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa # retrofit2 retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } +# coroutines +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" } + +# turbin +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }