diff --git a/README.md b/README.md index 328f251..8d8cf0f 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,8 @@ - [x] Loading : Indicator 출력, 에러 시 스낵바 출력 - [x] Empty : "목록이 비었습니다" 출력 - [x] Success : 리스트 출력 + +## 4단계 +- [x] 저장소의 Star 개수 노출 +- [x] 저장소의 Star 개수가 50개 이상이면 HOT 텍스트 노출 +- [x] (선택) 도메인 계층 구현 diff --git a/app/src/main/java/nextstep/AppContainer.kt b/app/src/main/java/nextstep/AppContainer.kt index 1c4a989..30d8fda 100644 --- a/app/src/main/java/nextstep/AppContainer.kt +++ b/app/src/main/java/nextstep/AppContainer.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.json.Json import nextstep.github.data.repository.GithubRepoRepository import nextstep.github.data.repository.impl.DefaultGithubRepoRepository import nextstep.github.data.service.GithubService +import nextstep.github.domain.usecase.DefaultGetRepositoriesUseCase +import nextstep.github.domain.usecase.GetRepositoriesUseCase import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -29,6 +31,9 @@ class AppContainer { val githubRepoRepository: GithubRepoRepository = DefaultGithubRepoRepository(githubService) + val getRepositoriesUseCase: GetRepositoriesUseCase = + DefaultGetRepositoriesUseCase(githubRepoRepository) + companion object { private const val CONTENT_TYPE = "application/json" private const val BASE_URL = "https://api.github.com" diff --git a/app/src/main/java/nextstep/github/data/repository/model/RepositoryEntity.kt b/app/src/main/java/nextstep/github/data/repository/model/RepositoryEntity.kt index 2969db5..1b80bda 100644 --- a/app/src/main/java/nextstep/github/data/repository/model/RepositoryEntity.kt +++ b/app/src/main/java/nextstep/github/data/repository/model/RepositoryEntity.kt @@ -8,4 +8,5 @@ data class RepositoryEntity( @SerialName("id") val id: Long, @SerialName("full_name") val fullName: String?, @SerialName("description") val description: String?, + @SerialName("stargazers_count") val stars: Int?, ) diff --git a/app/src/main/java/nextstep/github/domain/model/Repository.kt b/app/src/main/java/nextstep/github/domain/model/Repository.kt new file mode 100644 index 0000000..4b505b7 --- /dev/null +++ b/app/src/main/java/nextstep/github/domain/model/Repository.kt @@ -0,0 +1,26 @@ +package nextstep.github.domain.model + +import nextstep.github.data.repository.model.RepositoryEntity +import nextstep.github.util.orZero + +data class Repository( + val id: Long, + val fullName: String, + val description: String, + val stars: Int, +) { + val isHot: Boolean = stars >= HOT_STANDARD + + companion object { + private const val HOT_STANDARD = 50 + } +} + +fun RepositoryEntity.toDomain(): Repository { + return Repository( + id = id, + fullName = fullName.orEmpty(), + description = description.orEmpty(), + stars = stars.orZero() + ) +} diff --git a/app/src/main/java/nextstep/github/domain/usecase/GetRepositoriesUseCase.kt b/app/src/main/java/nextstep/github/domain/usecase/GetRepositoriesUseCase.kt new file mode 100644 index 0000000..928dd4f --- /dev/null +++ b/app/src/main/java/nextstep/github/domain/usecase/GetRepositoriesUseCase.kt @@ -0,0 +1,17 @@ +package nextstep.github.domain.usecase + +import nextstep.github.data.repository.GithubRepoRepository +import nextstep.github.domain.model.Repository +import nextstep.github.domain.model.toDomain + +fun interface GetRepositoriesUseCase { + suspend operator fun invoke(organization: String): List +} + +class DefaultGetRepositoriesUseCase( + private val githubRepoRepository: GithubRepoRepository, +) : GetRepositoriesUseCase { + override suspend operator fun invoke(organization: String): List { + return githubRepoRepository.getRepositories(organization).map { it.toDomain() } + } +} diff --git a/app/src/main/java/nextstep/github/ui/RepositoryListViewModel.kt b/app/src/main/java/nextstep/github/ui/RepositoryListViewModel.kt index a10e240..17fc8ea 100644 --- a/app/src/main/java/nextstep/github/ui/RepositoryListViewModel.kt +++ b/app/src/main/java/nextstep/github/ui/RepositoryListViewModel.kt @@ -6,43 +6,59 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import nextstep.github.GithubApplication -import nextstep.github.data.repository.GithubRepoRepository +import nextstep.github.R +import nextstep.github.domain.usecase.GetRepositoriesUseCase +import nextstep.github.ui.model.RepositoryListEvent import nextstep.github.ui.model.RepositoryListUiState class RepositoryListViewModel( - private val githubRepoRepository: GithubRepoRepository + private val getRepositoryUseCase: GetRepositoriesUseCase ) : ViewModel() { - val uiState = flow { - emit(RepositoryListUiState.Loading(error = false)) - - val repositories = githubRepoRepository.getRepositories(ORGANIZATION) - if (repositories.isEmpty()) { - emit(RepositoryListUiState.Empty) - } else { - emit(RepositoryListUiState.Success(items = repositories)) - } - }.catch { - emit(RepositoryListUiState.Loading(error = true)) - }.stateIn( + val uiState = getRepositoriesFlow().stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = RepositoryListUiState.Loading(error = false) + initialValue = RepositoryListUiState.Loading ) + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + private fun getRepositoriesFlow(): Flow { + return flow { + val repositories = getRepositoryUseCase(ORGANIZATION) + if (repositories.isEmpty()) { + emit(RepositoryListUiState.Empty) + } else { + emit(RepositoryListUiState.Success(items = repositories)) + } + }.catch { + emit(RepositoryListUiState.Loading) + _event.tryEmit( + RepositoryListEvent.ShowSnackBar( + msgRes = R.string.repository_list_fetch_error, + actionLabelRes = R.string.retry + ) + ) + } + } + companion object { private const val ORGANIZATION = "next-step" val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { - val githubRepository = (this[APPLICATION_KEY] as GithubApplication) + val getRepositoriesUseCase = (this[APPLICATION_KEY] as GithubApplication) .appContainer - .githubRepoRepository - RepositoryListViewModel(githubRepository) + .getRepositoriesUseCase + RepositoryListViewModel(getRepositoriesUseCase) } } } diff --git a/app/src/main/java/nextstep/github/ui/component/RepositoryItem.kt b/app/src/main/java/nextstep/github/ui/component/RepositoryItem.kt index 3602a1e..7650240 100644 --- a/app/src/main/java/nextstep/github/ui/component/RepositoryItem.kt +++ b/app/src/main/java/nextstep/github/ui/component/RepositoryItem.kt @@ -2,22 +2,50 @@ package nextstep.github.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon 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.graphics.Color +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.data.repository.model.RepositoryEntity +import nextstep.github.R +import nextstep.github.domain.model.Repository import nextstep.github.ui.theme.GithubTheme @Composable fun RepositoryItem( - repository: RepositoryEntity, + repository: Repository, + modifier: Modifier = Modifier +) { + RepositoryItem( + fullName = repository.fullName, + description = repository.description, + stars = repository.stars, + isHot = repository.isHot, + modifier = modifier + ) +} + +@Composable +fun RepositoryItem( + fullName: String, + description: String, + stars: Int, + isHot: Boolean, modifier: Modifier = Modifier ) { Column( @@ -25,15 +53,32 @@ fun RepositoryItem( .background(color = MaterialTheme.colorScheme.surface) ) { Column( - modifier = Modifier.padding(16.dp) + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() ) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + if (isHot) { + Text( + text = stringResource(R.string.repository_item_hot), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.weight(1f)) + StarCount( + count = stars, + ) + } Text( - text = repository.fullName.orEmpty(), + text = fullName, style = MaterialTheme.typography.titleLarge, color = Color.Black ) Text( - text = repository.description.orEmpty(), + text = description, style = MaterialTheme.typography.bodyMedium, color = Color.Black ) @@ -42,16 +87,45 @@ fun RepositoryItem( } } +@Composable +fun StarCount( + count: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(18.dp), + imageVector = Icons.Filled.Star, + contentDescription = null + ) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelLarge, + ) + } +} + +private class RepositoryItemPreviewParameterProvider : PreviewParameterProvider { + override val values = sequenceOf( + true, + false + ) +} + @Preview @Composable -private fun RepositoryItemPreview() { +private fun RepositoryItemPreview( + @PreviewParameter(RepositoryItemPreviewParameterProvider::class) value: Boolean +) { GithubTheme { RepositoryItem( - RepositoryEntity( - id = 0, - fullName = "next-step/nextstep-docs", - description = "nextstep 매뉴얼 및 문서를 관리하는 저장소" - ), + fullName = "next-step/nextstep-docs", + description = "nextstep 매뉴얼 및 문서를 관리하는 저장소", + stars = 100, + isHot = value, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/nextstep/github/ui/component/RepositoryList.kt b/app/src/main/java/nextstep/github/ui/component/RepositoryList.kt index d198925..1aac573 100644 --- a/app/src/main/java/nextstep/github/ui/component/RepositoryList.kt +++ b/app/src/main/java/nextstep/github/ui/component/RepositoryList.kt @@ -7,12 +7,12 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import nextstep.github.data.repository.model.RepositoryEntity +import nextstep.github.domain.model.Repository import nextstep.github.ui.theme.GithubTheme @Composable fun RepositoryList( - repositories: List, + repositories: List, modifier: Modifier = Modifier ) { LazyColumn( @@ -33,10 +33,11 @@ private fun RepositoryListPreview() { GithubTheme { RepositoryList( repositories = List(10) { - RepositoryEntity( + Repository( id = it.toLong(), fullName = "next-step/nextstep-docs", - description = "nextstep 매뉴얼 및 문서를 관리하는 저장소" + description = "nextstep 매뉴얼 및 문서를 관리하는 저장소", + stars = 500, ) }, modifier = Modifier.fillMaxSize() diff --git a/app/src/main/java/nextstep/github/ui/model/RepositoryListEvent.kt b/app/src/main/java/nextstep/github/ui/model/RepositoryListEvent.kt new file mode 100644 index 0000000..4b821fb --- /dev/null +++ b/app/src/main/java/nextstep/github/ui/model/RepositoryListEvent.kt @@ -0,0 +1,7 @@ +package nextstep.github.ui.model + +import androidx.annotation.StringRes + +sealed interface RepositoryListEvent { + data class ShowSnackBar(@StringRes val msgRes: Int, @StringRes val actionLabelRes: Int): RepositoryListEvent +} diff --git a/app/src/main/java/nextstep/github/ui/model/RepositoryListUiState.kt b/app/src/main/java/nextstep/github/ui/model/RepositoryListUiState.kt index 62c7b07..3620f60 100644 --- a/app/src/main/java/nextstep/github/ui/model/RepositoryListUiState.kt +++ b/app/src/main/java/nextstep/github/ui/model/RepositoryListUiState.kt @@ -1,9 +1,9 @@ package nextstep.github.ui.model -import nextstep.github.data.repository.model.RepositoryEntity +import nextstep.github.domain.model.Repository sealed interface RepositoryListUiState { - data class Loading(val error: Boolean): RepositoryListUiState + data object Loading : RepositoryListUiState data object Empty: RepositoryListUiState - data class Success(val items: List): RepositoryListUiState + data class Success(val items: List): RepositoryListUiState } diff --git a/app/src/main/java/nextstep/github/ui/screen/RepositoryListScreen.kt b/app/src/main/java/nextstep/github/ui/screen/RepositoryListScreen.kt index e830d62..2e329be 100644 --- a/app/src/main/java/nextstep/github/ui/screen/RepositoryListScreen.kt +++ b/app/src/main/java/nextstep/github/ui/screen/RepositoryListScreen.kt @@ -24,12 +24,14 @@ 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.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import nextstep.github.R -import nextstep.github.data.repository.model.RepositoryEntity +import nextstep.github.domain.model.Repository import nextstep.github.ui.RepositoryListViewModel import nextstep.github.ui.component.RepositoryList +import nextstep.github.ui.model.RepositoryListEvent import nextstep.github.ui.model.RepositoryListUiState import nextstep.github.ui.theme.GithubTheme @@ -39,9 +41,26 @@ fun RepositoryListScreen( viewModel: RepositoryListViewModel = viewModel(factory = RepositoryListViewModel.Factory) ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(lifecycleOwner) { + viewModel.event.collect { + when(it) { + is RepositoryListEvent.ShowSnackBar -> { + snackbarHostState.showSnackbar( + message = context.getString(it.msgRes), + actionLabel = context.getString(it.actionLabelRes) + ) + } + } + } + } RepositoryListScreen( uiState = uiState, + snackbarHostState = snackbarHostState, modifier = modifier ) } @@ -50,10 +69,9 @@ fun RepositoryListScreen( @Composable fun RepositoryListScreen( uiState: RepositoryListUiState, + snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier ) { - val snackbarHostState = remember { SnackbarHostState() } - Scaffold( modifier = modifier, topBar = { @@ -77,41 +95,11 @@ fun RepositoryListScreen( ) { paddingValues -> when (uiState) { RepositoryListUiState.Empty -> { - Box( - modifier = modifier - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.repository_list_empty_content), - style = MaterialTheme.typography.headlineSmall, - ) - } + RepositoryEmptyView(Modifier.fillMaxSize()) } is RepositoryListUiState.Loading -> { - val context = LocalContext.current - - LaunchedEffect(uiState.error) { - if (uiState.error) { - snackbarHostState.showSnackbar( - message = context.getString(R.string.repository_list_fetch_error), - actionLabel = context.getString(R.string.retry) - ) - } - } - - Box( - modifier = modifier - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary - ) - } + RepositoryLoadingView(Modifier.fillMaxSize()) } is RepositoryListUiState.Success -> { @@ -124,17 +112,43 @@ fun RepositoryListScreen( } } +@Composable +fun RepositoryEmptyView(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(color = MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.repository_list_empty_content), + style = MaterialTheme.typography.headlineSmall, + ) + } +} + +@Composable +fun RepositoryLoadingView(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background(color = MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + } +} + + class UiStatePreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf( RepositoryListUiState.Empty, - RepositoryListUiState.Loading(false), - RepositoryListUiState.Loading(true), + RepositoryListUiState.Loading, RepositoryListUiState.Success( List(10) { - RepositoryEntity( + Repository( id = it.toLong(), fullName = "next-step/nextstep-docs", - description = "nextstep 매뉴얼 및 문서를 관리하는 저장소" + description = "nextstep 매뉴얼 및 문서를 관리하는 저장소", + stars = 500 ) } ) @@ -147,8 +161,11 @@ private fun RepositoryListScreenPreview( @PreviewParameter(UiStatePreviewParameterProvider::class) uiState: RepositoryListUiState ) { GithubTheme { + val snackbarHostState = remember { SnackbarHostState() } + RepositoryListScreen( uiState = uiState, + snackbarHostState = snackbarHostState, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/nextstep/github/util/IntUtil.kt b/app/src/main/java/nextstep/github/util/IntUtil.kt new file mode 100644 index 0000000..99291b8 --- /dev/null +++ b/app/src/main/java/nextstep/github/util/IntUtil.kt @@ -0,0 +1,3 @@ +package nextstep.github.util + +fun Int?.orZero(): Int = this ?: 0 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d563d32..7af93c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,4 +4,5 @@ 목록이 비었습니다. 예상치 못한 오류가 발생했습니다. 재시도 + HOT