Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Step2 - GitHub(UI 레이어) #37

Open
wants to merge 4 commits into
base: yibeomseok
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@
- 힌트 코드를 참고하여 수동 DI를 구현한다
- 실제 서버 데이터가 잘 로드되는지 Log로 확인한다

## Step2

- NEXTSTEP 조직의 저장소 목록을 선형 리스트로 노출한다
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,7 @@ dependencies {

implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit2.kotlinx.serialization.converter)

testImplementation(libs.coroutines.test)
testImplementation(libs.turbine)
}
23 changes: 8 additions & 15 deletions app/src/main/java/nextstep/github/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<List<UiGitHubRepoInfo>> =
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
)
}
}
}
Expand Down
60 changes: 60 additions & 0 deletions app/src/main/java/nextstep/github/ui/repos/ReposErrorScreen.kt
Original file line number Diff line number Diff line change
@@ -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 = {},
)
}
}
31 changes: 31 additions & 0 deletions app/src/main/java/nextstep/github/ui/repos/ReposLoadingScreen.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions app/src/main/java/nextstep/github/ui/repos/ReposScreen.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions app/src/main/java/nextstep/github/ui/repos/ReposSuccessScreen.kt
Original file line number Diff line number Diff line change
@@ -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<UiGitHubRepoInfo>,
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<UiGitHubRepoInfo>
)

private class ReposSuccessScreenPreviewParameterProvider :
PreviewParameterProvider<ReposSuccessScreenPreviewParameter> {
override val values: Sequence<ReposSuccessScreenPreviewParameter> = 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)
}
}

9 changes: 9 additions & 0 deletions app/src/main/java/nextstep/github/ui/repos/ReposUiState.kt
Original file line number Diff line number Diff line change
@@ -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<UiGitHubRepoInfo>) : ReposUiState
data class Error(val throwable: Throwable) : ReposUiState
}
62 changes: 62 additions & 0 deletions app/src/main/java/nextstep/github/ui/repos/ReposViewModel.kt
Original file line number Diff line number Diff line change
@@ -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<ReposUiState> = 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)
}
}
}
}

Loading