From a3b37f8a57aa5987371260eb3279251d04e932da Mon Sep 17 00:00:00 2001 From: simjunbo Date: Sun, 8 Sep 2024 19:43:40 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=20string=20resource=EB=A1=9C=20=EA=B4=80=EB=A6=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/nextstep/github/ui/github/GithubRepoListScreen.kt | 4 +++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt index 73cbcba2..190611f3 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt @@ -15,10 +15,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import nextstep.github.R import nextstep.github.model.GithubRepo import nextstep.github.ui.github.component.GithubRepoItem import nextstep.github.ui.theme.GithubTheme @@ -41,7 +43,7 @@ private fun GithubRepositoryListScreen( Scaffold( topBar = { CenterAlignedTopAppBar( - title = { Text("NEXTSTEP Repositories") } + title = { Text(stringResource(R.string.top_bar_title)) } ) } ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e6c1a9..30a878eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ GitHub + "NEXTSTEP Repositories" From 8326b497dcff210c75fc13e56af767940bddb2a7 Mon Sep 17 00:00:00 2001 From: simjunbo Date: Sun, 8 Sep 2024 20:58:20 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20nullable=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20nonNull=20=ED=98=95=ED=83=9C=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/nextstep/github/data/model/GithubRepoResponse.kt | 2 +- app/src/main/java/nextstep/github/model/GithubRepo.kt | 2 +- .../java/nextstep/github/ui/github/component/GithubRepoItem.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/nextstep/github/data/model/GithubRepoResponse.kt b/app/src/main/java/nextstep/github/data/model/GithubRepoResponse.kt index 572b7b43..d0787d0b 100644 --- a/app/src/main/java/nextstep/github/data/model/GithubRepoResponse.kt +++ b/app/src/main/java/nextstep/github/data/model/GithubRepoResponse.kt @@ -9,7 +9,7 @@ data class GithubRepoResponse( @SerialName("id") val id: Long, @SerialName("full_name") - val fullName: String?, + val fullName: String, @SerialName("description") val description: String?, ) diff --git a/app/src/main/java/nextstep/github/model/GithubRepo.kt b/app/src/main/java/nextstep/github/model/GithubRepo.kt index 349cce37..35ce75bd 100644 --- a/app/src/main/java/nextstep/github/model/GithubRepo.kt +++ b/app/src/main/java/nextstep/github/model/GithubRepo.kt @@ -2,6 +2,6 @@ package nextstep.github.model data class GithubRepo( val id: Long, - val fullName: String?, + val fullName: String, val description: String?, ) \ No newline at end of file diff --git a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoItem.kt b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoItem.kt index 2e62a685..2272d50a 100644 --- a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoItem.kt +++ b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoItem.kt @@ -23,7 +23,7 @@ fun GithubRepoItem( .padding(16.dp), ) { Text( - text = githubRepo.fullName ?: "Unknown", + text = githubRepo.fullName, style = MaterialTheme.typography.titleLarge, ) Text( From ae15834629da4f1f1ee5bff2a680cb1c51c54ada Mon Sep 17 00:00:00 2001 From: simjunbo Date: Mon, 9 Sep 2024 21:28:25 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20Github=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20UI=20=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../github/ui/github/GithubRepoListScreen.kt | 112 +++++++++++++----- .../github/ui/github/GithubRepoUiState.kt | 9 ++ .../github/ui/github/GithubRepoViewModel.kt | 43 +++++-- .../ui/github/component/GithubRepoLoading.kt | 27 +++++ .../ui/github/component/GithubRepoSuccess.kt | 41 +++++++ app/src/main/res/values/strings.xml | 2 + 6 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/nextstep/github/ui/github/GithubRepoUiState.kt create mode 100644 app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt create mode 100644 app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt index 190611f3..6c57eea4 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt @@ -1,28 +1,29 @@ package nextstep.github.ui.github -import android.util.Log -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch import nextstep.github.R import nextstep.github.model.GithubRepo -import nextstep.github.ui.github.component.GithubRepoItem +import nextstep.github.ui.github.component.GithubRepoLoading +import nextstep.github.ui.github.component.GithubRepoSuccess import nextstep.github.ui.theme.GithubTheme @Composable @@ -30,14 +31,33 @@ fun GithubRepositoryListScreen( modifier: Modifier = Modifier, viewModel: GithubRepoViewModel = viewModel(factory = GithubRepoViewModel.Factory), ) { - val repositoryList by viewModel.githubRepositories.collectAsStateWithLifecycle() - GithubRepositoryListScreen(repositories = repositoryList) + val uiState by viewModel.githubUiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val snackBarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(true) { + viewModel.errorFlow.collect { + coroutineScope.launch { + val snackBarResult = snackBarHostState.showSnackbar( + message = context.getString(R.string.error_message), + actionLabel = context.getString(R.string.error_retry) + ) + if (snackBarResult == SnackbarResult.ActionPerformed) run { viewModel::retryGithubRepo } + } + } + } + GithubRepositoryListScreen( + uiState = uiState, + snackBarHostState = snackBarHostState, + ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GithubRepositoryListScreen( - repositories: List, + uiState: GithubRepoUiState, + snackBarHostState: SnackbarHostState, modifier: Modifier = Modifier ) { Scaffold( @@ -45,18 +65,16 @@ private fun GithubRepositoryListScreen( CenterAlignedTopAppBar( title = { Text(stringResource(R.string.top_bar_title)) } ) - } + }, + snackbarHost = { SnackbarHost(hostState = snackBarHostState)} ) { - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(it), - ) { - items(repositories) {repo -> - GithubRepoItem(githubRepo = repo) - HorizontalDivider() - } + if (uiState.isLoading) { + GithubRepoLoading(modifier = Modifier.padding(it)) } + GithubRepoSuccess( + modifier = Modifier.padding(it), + repositories = uiState.repositories + ) } } @@ -65,14 +83,50 @@ private fun GithubRepositoryListScreen( private fun GithubRepositoryListScreenPreview() { GithubTheme { GithubRepositoryListScreen( - repositories = listOf( - GithubRepo(1, "NextStep/Test", "테스트 저장소"), - GithubRepo(2, "NextStep/Test2", "테스트 저장소2"), - GithubRepo(3, "NextStep/Test3", "테스트 저장소3"), - GithubRepo(4, "NextStep/Test4", "테스트 저장소4"), - GithubRepo(5, "NextStep/Test5", "테스트 저장소5"), - GithubRepo(6, "NextStep/Test6", "테스트 저장소6"), + uiState = GithubRepoUiState( + isLoading = false, + repositories = listOf( + GithubRepo(1, "NextStep/Test", "테스트 저장소"), + GithubRepo(2, "NextStep/Test2", "테스트 저장소2"), + GithubRepo(3, "NextStep/Test3", "테스트 저장소3"), + GithubRepo(4, "NextStep/Test4", "테스트 저장소4"), + GithubRepo(5, "NextStep/Test5", "테스트 저장소5"), + GithubRepo(6, "NextStep/Test6", "테스트 저장소6"), + ), + ), + snackBarHostState = SnackbarHostState() + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GithubRepositoryListScreenErrorPreview() { + val coroutineScope = rememberCoroutineScope() + val snackBarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + coroutineScope.launch { + snackBarHostState.showSnackbar( + message = "에러 메세지", + actionLabel = "재시도" ) + } + } + GithubTheme { + GithubRepositoryListScreen( + uiState = GithubRepoUiState(isLoading = true), + snackBarHostState = snackBarHostState + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GithubRepositoryListScreenLoadingPreview() { + GithubTheme { + GithubRepositoryListScreen( + uiState = GithubRepoUiState(isLoading = true), + snackBarHostState = SnackbarHostState() ) } } \ No newline at end of file diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoUiState.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoUiState.kt new file mode 100644 index 00000000..f7c7f4bd --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoUiState.kt @@ -0,0 +1,9 @@ +package nextstep.github.ui.github + +import nextstep.github.model.GithubRepo + +data class GithubRepoUiState ( + val isLoading: Boolean = false, + val repositories: List = emptyList(), +) + diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt index 7779aa69..cb352572 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt @@ -5,25 +5,46 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import nextstep.github.GithubApplication import nextstep.github.data.repository.GithubRepository -import nextstep.github.model.GithubRepo class GithubRepoViewModel( private val githubRepoRepository: GithubRepository, ) : ViewModel() { - val githubRepositories: StateFlow> = flow> { - emit(githubRepoRepository.getRepositories("next-step")) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - emptyList() - ) + private val _githubUiState: MutableStateFlow = MutableStateFlow(GithubRepoUiState()) + val githubUiState: StateFlow = _githubUiState.asStateFlow() + + private val _errorFlow = MutableSharedFlow() + val errorFlow = _errorFlow.asSharedFlow() + + init { + fetchRepositories() + } + + private fun fetchRepositories() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _errorFlow.tryEmit(throwable) + }) { + _githubUiState.update { it.copy(isLoading = true) } + val repositories = githubRepoRepository.getRepositories("next-step") + _githubUiState.update { it.copy(repositories = repositories, isLoading = false) } + } + } + + fun retryGithubRepo() { + fetchRepositories() + } + companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { diff --git a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt new file mode 100644 index 00000000..6d9f36e5 --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt @@ -0,0 +1,27 @@ +package nextstep.github.ui.github.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun GithubRepoLoading( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Preview(showBackground = true) +@Composable +private fun GithubRepoLoadingPreview() { + GithubRepoLoading() +} diff --git a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt new file mode 100644 index 00000000..00fde888 --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt @@ -0,0 +1,41 @@ +package nextstep.github.ui.github.component + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import nextstep.github.model.GithubRepo +import nextstep.github.ui.theme.GithubTheme + +@Composable +fun GithubRepoSuccess( + repositories: List, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier.fillMaxSize()) { + items(repositories) { repo -> + GithubRepoItem(githubRepo = repo) + HorizontalDivider() + } + } +} + +@Preview(showBackground = true) +@Composable +private fun GithubRepoSuccessPreview() { + GithubTheme { + GithubRepoSuccess( + repositories = listOf( + GithubRepo(1, "NextStep/Test", "테스트 저장소"), + GithubRepo(2, "NextStep/Test2", "테스트 저장소2"), + GithubRepo(3, "NextStep/Test3", "테스트 저장소3"), + GithubRepo(4, "NextStep/Test4", "테스트 저장소4"), + GithubRepo(5, "NextStep/Test5", "테스트 저장소5"), + GithubRepo(6, "NextStep/Test6", "테스트 저장소6"), + ) + ) + } +} \ 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 30a878eb..6db58a66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ GitHub "NEXTSTEP Repositories" + 예상치 못한 오류가 발생했습니다. + 재시도 From e1d476b911150feead4627dc5af79f382f38d5e2 Mon Sep 17 00:00:00 2001 From: simuelunbo Date: Mon, 16 Sep 2024 20:14:03 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20GithubReposSuccess=EC=97=90?= =?UTF-8?q?=EC=84=9C=20GithubRepoList=EB=A1=9C=20=EC=A7=81=EA=B4=80?= =?UTF-8?q?=EC=A0=81=EC=9D=B8=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nextstep/github/ui/github/GithubRepoListScreen.kt | 4 ++-- .../component/{GithubRepoSuccess.kt => GithubRepoList.kt} | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) rename app/src/main/java/nextstep/github/ui/github/component/{GithubRepoSuccess.kt => GithubRepoList.kt} (88%) diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt index 6c57eea4..ecaf31c5 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.launch import nextstep.github.R import nextstep.github.model.GithubRepo import nextstep.github.ui.github.component.GithubRepoLoading -import nextstep.github.ui.github.component.GithubRepoSuccess +import nextstep.github.ui.github.component.GithubRepoList import nextstep.github.ui.theme.GithubTheme @Composable @@ -71,7 +71,7 @@ private fun GithubRepositoryListScreen( if (uiState.isLoading) { GithubRepoLoading(modifier = Modifier.padding(it)) } - GithubRepoSuccess( + GithubRepoList( modifier = Modifier.padding(it), repositories = uiState.repositories ) diff --git a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoList.kt similarity index 88% rename from app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt rename to app/src/main/java/nextstep/github/ui/github/component/GithubRepoList.kt index 00fde888..de4be0b5 100644 --- a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoSuccess.kt +++ b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoList.kt @@ -11,7 +11,7 @@ import nextstep.github.model.GithubRepo import nextstep.github.ui.theme.GithubTheme @Composable -fun GithubRepoSuccess( +fun GithubRepoList( repositories: List, modifier: Modifier = Modifier, ) { @@ -23,11 +23,11 @@ fun GithubRepoSuccess( } } -@Preview(showBackground = true) +@Preview(showBackground = true, name = "Github 저장소 리스트 불러오기 성공") @Composable -private fun GithubRepoSuccessPreview() { +private fun GithubRepoListPreview() { GithubTheme { - GithubRepoSuccess( + GithubRepoList( repositories = listOf( GithubRepo(1, "NextStep/Test", "테스트 저장소"), GithubRepo(2, "NextStep/Test2", "테스트 저장소2"), From a00a8a9fd29d1db83c249964f0bad9d36da0a97f Mon Sep 17 00:00:00 2001 From: simuelunbo Date: Mon, 16 Sep 2024 20:45:15 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20Github=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=98=B8=EC=B6=9C=20=EC=97=90=EB=9F=AC=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=88=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/nextstep/github/ui/github/GithubRepoListScreen.kt | 2 +- .../main/java/nextstep/github/ui/github/GithubRepoViewModel.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt index ecaf31c5..1af00438 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt @@ -114,7 +114,7 @@ private fun GithubRepositoryListScreenErrorPreview() { } GithubTheme { GithubRepositoryListScreen( - uiState = GithubRepoUiState(isLoading = true), + uiState = GithubRepoUiState(isLoading = false), snackBarHostState = snackBarHostState ) } diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt index cb352572..81d9ab65 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt @@ -33,6 +33,7 @@ class GithubRepoViewModel( private fun fetchRepositories() { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _githubUiState.update { it.copy(isLoading = false) } _errorFlow.tryEmit(throwable) }) { _githubUiState.update { it.copy(isLoading = true) } From fa8a7849d69e3ef490f935563dbda527137b3a09 Mon Sep 17 00:00:00 2001 From: simjunbo Date: Sun, 22 Sep 2024 18:53:15 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20GitHub=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EB=AA=A9=EB=A1=9D=20=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=B9=88=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/nextstep/github/ui/github/GithubRepoListScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt index 1af00438..0ec9ebcb 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt @@ -43,7 +43,7 @@ fun GithubRepositoryListScreen( message = context.getString(R.string.error_message), actionLabel = context.getString(R.string.error_retry) ) - if (snackBarResult == SnackbarResult.ActionPerformed) run { viewModel::retryGithubRepo } + if (snackBarResult == SnackbarResult.ActionPerformed) viewModel.retryGithubRepo() } } } From 120e69637255c1e66783220b13be3387c5ba5e5b Mon Sep 17 00:00:00 2001 From: simjunbo Date: Sun, 22 Sep 2024 19:30:17 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BD=94=EB=A3=A8=ED=8B=B4=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../github/ui/github/GithubRepoViewModel.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt index 81d9ab65..b1b52d85 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoViewModel.kt @@ -32,13 +32,15 @@ class GithubRepoViewModel( } private fun fetchRepositories() { - viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> - _githubUiState.update { it.copy(isLoading = false) } - _errorFlow.tryEmit(throwable) - }) { + viewModelScope.launch { _githubUiState.update { it.copy(isLoading = true) } - val repositories = githubRepoRepository.getRepositories("next-step") - _githubUiState.update { it.copy(repositories = repositories, isLoading = false) } + runCatching { + val repositories = githubRepoRepository.getRepositories("next-step") + _githubUiState.update { it.copy(repositories = repositories, isLoading = false) } + }.onFailure { throwable -> + _githubUiState.update { it.copy(isLoading = false) } + _errorFlow.emit(throwable) + } } } From 54780cc3f24b8112ed6ffb20d43c5f9b9328f80e Mon Sep 17 00:00:00 2001 From: junbo sim Date: Mon, 23 Sep 2024 16:02:43 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20LoadingIndicator=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=83=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nextstep/github/ui/github/component/GithubRepoLoading.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt index 6d9f36e5..2a122838 100644 --- a/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt +++ b/app/src/main/java/nextstep/github/ui/github/component/GithubRepoLoading.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview @Composable @@ -13,7 +14,9 @@ fun GithubRepoLoading( modifier: Modifier = Modifier, ) { Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .testTag("LoadingIndicator"), contentAlignment = Alignment.Center ) { CircularProgressIndicator() From 0005fc19481ae1b7b7c229683e8167fb3a867b57 Mon Sep 17 00:00:00 2001 From: junbo sim Date: Mon, 23 Sep 2024 16:03:40 +0900 Subject: [PATCH 09/11] =?UTF-8?q?test:=20GithubRepositoryListScreen=20UI?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/github/GithubRepoListScreenKtTest.kt | 99 +++++++++++++++++++ .../github/ui/github/GithubRepoListScreen.kt | 2 +- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/nextstep/github/ui/github/GithubRepoListScreenKtTest.kt diff --git a/app/src/androidTest/java/nextstep/github/ui/github/GithubRepoListScreenKtTest.kt b/app/src/androidTest/java/nextstep/github/ui/github/GithubRepoListScreenKtTest.kt new file mode 100644 index 00000000..fbf8622d --- /dev/null +++ b/app/src/androidTest/java/nextstep/github/ui/github/GithubRepoListScreenKtTest.kt @@ -0,0 +1,99 @@ +package nextstep.github.ui.github + + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import nextstep.github.model.GithubRepo +import nextstep.github.ui.theme.GithubTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GithubRepositoryListScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun 로딩중일때_LoadingIndicator가_나온다() { + // Given + val uiState = GithubRepoUiState(isLoading = true) + + // When + composeTestRule.setContent { + GithubTheme { + GithubRepositoryListScreen( + uiState = uiState, snackBarHostState = SnackbarHostState() + ) + } + } + + // Then + composeTestRule.onNodeWithTag("LoadingIndicator").assertExists() + } + + @Test + fun 저장소를_성공적으로_가져왔을때_저장소리스트가_보여진다() { + // Given + val repositories = listOf( + GithubRepo(1, "NextStep/Test", "테스트 저장소"), + GithubRepo(2, "NextStep/Test2", "테스트 저장소2"), + GithubRepo(3, "NextStep/Test3", "테스트 저장소3") + ) + val uiState = GithubRepoUiState( + isLoading = false, repositories = repositories + ) + + // When + composeTestRule.setContent { + GithubTheme { + GithubRepositoryListScreen( + uiState = uiState, snackBarHostState = SnackbarHostState() + ) + } + } + + // Then + repositories.forEach { repo -> + composeTestRule.onNodeWithText(repo.fullName).assertExists() + val description = repo.description + if (description != null) { + composeTestRule.onNodeWithText(description).assertExists() + } + } + } + + @Test + fun 에러가_발생하면_스낵바가_나온다() { + // Given + val uiState = GithubRepoUiState() + + // When + composeTestRule.setContent { + val snackBarHostState = remember { SnackbarHostState() } + LaunchedEffect(true) { + snackBarHostState.showSnackbar( + message = "에러 메세지", actionLabel = "재시도" + ) + } + GithubTheme { + GithubRepositoryListScreen( + uiState = uiState, snackBarHostState = snackBarHostState + ) + } + } + + // Then + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("에러 메세지").assertExists() + composeTestRule.onNodeWithText("재시도").assertExists() + + } +} + diff --git a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt index 0ec9ebcb..d3363e66 100644 --- a/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/github/GithubRepoListScreen.kt @@ -55,7 +55,7 @@ fun GithubRepositoryListScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun GithubRepositoryListScreen( +fun GithubRepositoryListScreen( uiState: GithubRepoUiState, snackBarHostState: SnackbarHostState, modifier: Modifier = Modifier From 76712412415d4ba1cd9a4bce744bd6d1f4f2a7ca Mon Sep 17 00:00:00 2001 From: junbo sim Date: Wed, 25 Sep 2024 17:53:44 +0900 Subject: [PATCH 10/11] =?UTF-8?q?chore:=20test=20dependency=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 ++ gradle/libs.versions.toml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3bd8504f..a4252f14 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,7 +66,9 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.junit.ktx) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b48dc27..3b7510b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,8 @@ composeBom = "2024.06.00" kotlinxSerializationJson = "1.6.3" retrofit2KotlinxSerializationConverter = "1.0.0" okhttp = "4.12.0" +junitKtx = "1.2.1" +coroutinesTest = "1.6.0" [libraries] @@ -34,6 +36,8 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 02bc6e186068af2f0732b70553aac91d0225b275 Mon Sep 17 00:00:00 2001 From: junbo sim Date: Thu, 26 Sep 2024 15:56:21 +0900 Subject: [PATCH 11/11] =?UTF-8?q?test:=20GithubRepoViewModel=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nextstep/github/MainDispatcherRule.kt | 23 ++++++ .../github/repository/FakeGithubRepository.kt | 42 ++++++++++ .../ui/github/GithubRepoViewModelTest.kt | 80 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 app/src/test/java/nextstep/github/MainDispatcherRule.kt create mode 100644 app/src/test/java/nextstep/github/repository/FakeGithubRepository.kt create mode 100644 app/src/test/java/nextstep/github/ui/github/GithubRepoViewModelTest.kt diff --git a/app/src/test/java/nextstep/github/MainDispatcherRule.kt b/app/src/test/java/nextstep/github/MainDispatcherRule.kt new file mode 100644 index 00000000..2ba8b1b1 --- /dev/null +++ b/app/src/test/java/nextstep/github/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package nextstep.github + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher, +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/app/src/test/java/nextstep/github/repository/FakeGithubRepository.kt b/app/src/test/java/nextstep/github/repository/FakeGithubRepository.kt new file mode 100644 index 00000000..424a6a44 --- /dev/null +++ b/app/src/test/java/nextstep/github/repository/FakeGithubRepository.kt @@ -0,0 +1,42 @@ +package nextstep.github.repository + +import nextstep.github.data.repository.GithubRepository +import nextstep.github.model.GithubRepo + +class FakeGithubRepository : GithubRepository { + var shouldReturnException = false + + override suspend fun getRepositories(organization: String): List { + if (shouldReturnException) { + throw Exception("network error") + } + val dummyRepos = listOf( + GithubRepo( + id = 1, + fullName = "octocat/Hello-World", + description = "This is your first repository" + ), + GithubRepo( + id = 2, + fullName = "google/androidx", + description = "Development environment for Android Jetpack extension libraries" + ), + GithubRepo( + id = 3, + fullName = "kotlin/kotlin", + description = "The Kotlin Programming Language" + ), + GithubRepo( + id = 4, + fullName = "square/retrofit", + description = "A type-safe HTTP client for Android and the JVM" + ), + GithubRepo( + id = 5, + fullName = "JetBrains/compose-multiplatform", + description = null + ) + ) + return dummyRepos + } +} diff --git a/app/src/test/java/nextstep/github/ui/github/GithubRepoViewModelTest.kt b/app/src/test/java/nextstep/github/ui/github/GithubRepoViewModelTest.kt new file mode 100644 index 00000000..8d2bf4fc --- /dev/null +++ b/app/src/test/java/nextstep/github/ui/github/GithubRepoViewModelTest.kt @@ -0,0 +1,80 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package nextstep.github.ui.github + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import nextstep.github.MainDispatcherRule +import nextstep.github.model.GithubRepo +import nextstep.github.repository.FakeGithubRepository +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + + +class GithubRepoViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private lateinit var fakeGithubRepository: FakeGithubRepository + private lateinit var viewModel: GithubRepoViewModel + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(testDispatcher) + + @Before + fun setUp() { + fakeGithubRepository = FakeGithubRepository() + viewModel = GithubRepoViewModel(fakeGithubRepository) + } + + @Test + fun 레포목록_가져오기_성공_하면_레포목록이_업데이트된다() = runTest { + + advanceUntilIdle() + val uiState = viewModel.githubUiState.value + val expected = listOf( + GithubRepo( + id = 1, + fullName = "octocat/Hello-World", + description = "This is your first repository" + ), GithubRepo( + id = 2, + fullName = "google/androidx", + description = "Development environment for Android Jetpack extension libraries" + ), GithubRepo( + id = 3, fullName = "kotlin/kotlin", description = "The Kotlin Programming Language" + ), GithubRepo( + id = 4, + fullName = "square/retrofit", + description = "A type-safe HTTP client for Android and the JVM" + ), GithubRepo( + id = 5, fullName = "JetBrains/compose-multiplatform", description = null + ) + ) + assertEquals(expected, uiState.repositories) + + } + + @Test + fun 에러_발생시_isLoading은_false이고_에러가_전달되어야_한다() = testScope.runTest { + fakeGithubRepository.shouldReturnException = true + viewModel = GithubRepoViewModel(fakeGithubRepository) + + val uiState = viewModel.githubUiState.value + assertFalse(uiState.isLoading) + assertTrue(uiState.repositories.isEmpty()) + + val error = viewModel.errorFlow.first() + assertNotNull(error) + assertEquals("network error", error.message) + } + +}