Skip to content
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@
- [x] λΉ„μ§€λ‹ˆμŠ€ 둜직 κ΅¬ν˜„
- [x] UI μ—°κ²°
- [x] ViewModelFactory

# step3 UI state

## Response 응닡에 λ”°λ₯Έ μƒνƒœ UI λ…ΈμΆœ

- [x] λͺ©λ‘ λ‘œλ”© μ „, λ‘œλ”© UI λ…ΈμΆœ
- [x] 빈 λͺ©λ‘μΌ 경우, 빈 ν™”λ©΄ UI λ…ΈμΆœ
- [x] 였λ₯˜ λ°œμƒ μ‹œ, μž¬μ‹œλ„ κ°€λŠ₯ν•œ μŠ€λ‚΅λ°” λ…ΈμΆœ
- [x] UI ν…ŒμŠ€νŠΈ μž‘μ„± -> Preview 둜 λŒ€μ²΄
6 changes: 1 addition & 5 deletions app/src/main/java/nextstep/github/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@ package nextstep.github
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import nextstep.github.ui.GithubRepositoryScreen
import nextstep.github.ui.GithubRepositoryViewModel

class MainActivity : ComponentActivity() {

private val viewModel: GithubRepositoryViewModel by viewModels<GithubRepositoryViewModel> { GithubRepositoryViewModel.Factory }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
GithubRepositoryScreen(viewModel = viewModel)
GithubRepositoryScreen()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import nextstep.github.network.Result
class GithubRemoteDataSource(
private val githubService: GithubService
) {
suspend fun fetchRepositories(organization: String): Result<List<GithubRepositoryEntity>> {
return githubService.getRepositories(organization)
suspend fun fetchRepositories(
organization: String,
onPreLoad: () -> Unit = {},
): Result<List<GithubRepositoryEntity>> {
return githubService
.also { onPreLoad.invoke() }
.getRepositories(organization)
}
}
10 changes: 8 additions & 2 deletions app/src/main/java/nextstep/github/data/GithubRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ class GithubRepository(
private val remoteDataSource: GithubRemoteDataSource = GithubRemoteDataSource(githubService)
private val localDataSource: GithubLocalDataSource = GithubLocalDataSource()

suspend fun getRepositories(organization: String): Result<List<GithubRepositoryEntity>> {
return remoteDataSource.fetchRepositories(organization)
suspend fun getRepositories(
organization: String,
onPreLoad: () -> Unit
): Result<List<GithubRepositoryEntity>> {
return remoteDataSource.fetchRepositories(
organization = organization,
onPreLoad = onPreLoad
)
}
}
6 changes: 4 additions & 2 deletions app/src/main/java/nextstep/github/network/Result.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package nextstep.github.network

sealed class Result<out R> {
fun onSuccess(func: (R) -> Unit) {
fun onSuccess(func: (R) -> Unit): Result<R> {
if (this is Success) {
func.invoke(this.data)
}
return this
}

fun onError(func: (Throwable) -> Unit) {
fun onError(func: (Throwable) -> Unit): Result<R> {
if (this is Error) {
func.invoke(this.exception)
}
return this
}

data class Success<out T>(val data: T) : Result<T>()
Expand Down
110 changes: 103 additions & 7 deletions app/src/main/java/nextstep/github/ui/GithubRepositoryScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,132 @@ package nextstep.github.ui

import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import nextstep.github.ui.component.GithubRepositoryEmpty
import nextstep.github.ui.component.GithubRepositoryList
import nextstep.github.ui.component.GithubRepositoryLoading
import nextstep.github.ui.component.GithubRepositorySnackBar
import nextstep.github.ui.component.GithubRepositoryTopBar
import nextstep.github.ui.component.dummyList
import nextstep.github.ui.theme.GithubTheme

@Composable
internal fun GithubRepositoryScreen(
viewModel: GithubRepositoryViewModel = viewModel<GithubRepositoryViewModel>(factory = GithubRepositoryViewModel.Factory),
) {
val githubRepositories by viewModel.githubRepositories.collectAsStateWithLifecycle()
val repositoryUiState by viewModel.state.repositoryUiState.collectAsStateWithLifecycle()

GithubRepositoryScreen(
repositoryUiState = repositoryUiState,
showSnackBar = repositoryUiState is GithubRepositoryState.RepositoryUiState.Error,
onClickSnackBarRetry = { viewModel.loadRepositories() }
)
}

@Composable
private fun GithubRepositoryScreen(
repositoryUiState: GithubRepositoryState.RepositoryUiState,
showSnackBar: Boolean,
onClickSnackBarRetry: () -> Unit,
) {

val snackBarHostState = remember { SnackbarHostState() }

Scaffold(
topBar = { GithubRepositoryTopBar() }
topBar = { GithubRepositoryTopBar() },
snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
) { innerPadding ->
GithubRepositoryList(
model = githubRepositories,
modifier = Modifier.padding(innerPadding),
val modifier = Modifier.padding(innerPadding)

when (repositoryUiState) {
is GithubRepositoryState.RepositoryUiState.Loading -> GithubRepositoryLoading(modifier)
is GithubRepositoryState.RepositoryUiState.Empty -> GithubRepositoryEmpty(modifier)
is GithubRepositoryState.RepositoryUiState.Data -> GithubRepositoryList(
model = repositoryUiState.items,
modifier = modifier
)

else -> {
/** do nothing */
}
}
}

if (showSnackBar) {
GithubRepositorySnackBar(
snackBarHostState = snackBarHostState,
onRetryAction = {
onClickSnackBarRetry()
}
)
}
}

@Composable
private fun GithubRepositoryScreen(
repositoryUiState: GithubRepositoryState.RepositoryUiState,
) {
GithubRepositoryScreen(
repositoryUiState = repositoryUiState,
showSnackBar = false,
onClickSnackBarRetry = {},
)
}

@Preview
@Composable
private fun GithubRepositoryScreePreview() {
private fun GithubRepositoryScreeLoadingPreview() {
GithubTheme {
GithubRepositoryScreen()
GithubRepositoryScreen(repositoryUiState = GithubRepositoryState.RepositoryUiState.Loading)
}
}

@Preview
@Composable
private fun GithubRepositoryScreeEmptyPreview() {
GithubTheme {
GithubRepositoryScreen(repositoryUiState = GithubRepositoryState.RepositoryUiState.Empty)
}
}

@Preview
@Composable
private fun GithubRepositoryScreeRepositoryPreview() {
GithubTheme {
GithubRepositoryScreen(
repositoryUiState = GithubRepositoryState.RepositoryUiState.Data(
dummyList()
)
)
}
}

@Preview
@Composable
private fun GithubRepositoryScreeErrorPreview() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

μ—λŸ¬ 화면일 경우 PreviewλŠ” μ–΄λ–»κ²Œ 확인해보면 λ˜λŠ”κ±ΈκΉŒμš”? πŸ€”
λ§Œμ•½ 방법을 μ•„μ‹ λ‹€λ©΄, μ–΄λ–»κ²Œ ν™•μΈν•΄μ•Όν•˜λŠ”μ§€ μ½”λ“œλ§Œ 보고 인지할 수 있으면 쒋을 것 같은데 μƒμ•„λ‹˜μ€ μ–΄λ–»κ²Œ μƒκ°ν•˜μ‹œλ‚˜μš”?

Copy link
Author

@ethanchaee ethanchaee Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

μ§ˆλ¬Έμ„ 잘 μ΄ν•΄ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€.

ν•΄λ‹Ή preview λΉŒλ“œ μ‹œ, μ—λŸ¬ 화면이 λ‘œλ“œ 되고, μž¬μ‹œλ„ λ²„νŠΌ 클릭 μ‹œ, λΉˆν™”λ©΄μ΄ λ…ΈμΆœλ˜κ²Œ ν•΄μ„œ μƒνƒœμ— λ”°λ₯Έ 변화도 μ—…λ°μ΄νŠΈ λ˜λŠ” 것 ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€.

μ½”λ“œμ—μ„œ
졜초 repositoryState λ₯Ό Error 둜 ν• λ‹Ήν•˜κ³ ,
retryAction 클릭 μ‹œ, respositoryState λ₯Ό empty 둜 μ—…λ°μ΄νŠΈ ν•΄μ„œ 확인할 수 있게 κ°œλ°œν–ˆμŠ΅λ‹ˆλ‹€.

μ–΄λ–»κ²Œ 확인해야 ν•˜λŠ”μ§€ μ½”λ“œλ§Œ 보고 μΈμ§€ν•˜κ²Œ ν•˜λ €λ©΄, μ£Όμ„μœΌλ‘œ ν…ŒμŠ€νŠΈ 방법을 써놔야 ν• κΉŒμš”?
preview λΉŒλ“œ ν•΄λ³΄κ³ λ‚˜ κ°„λ‹¨νžˆ interaction mode μ΄μš©ν•˜λ©΄ λ°”λ‘œ 확인할 수 μžˆμ„κ±°λΌ μƒκ°ν–ˆλŠ”λ° μ–΄λ–€ 뢀뢄이 μ–΄λ €μš°μ…¨μ„κΉŒμš”?

Screen_recording_20250312_231800.webm

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ethanchaee
일반적으둜 PreviewλŠ” μ•ˆλ“œλ‘œμ΄λ“œ μŠ€νŠœλ””μ˜€ μƒμ—μ„œ UIκ°€ μ–΄λ–»κ²Œ κ·Έλ €μ§€λŠ”μ§€ ν™•μΈν• ν…λ°μš”.
μƒμ•„λ‹˜κ»˜μ„œλŠ” ν•΄λ‹Ή PreviewλŠ” λΉŒλ“œλ₯Ό ν•΄μ„œ 직접 ν™•μΈν•΄λ΄μ•Όν•œλ‹€λŠ” 것을 μ•„μ‹œμ§€λ§Œ, λ™λ£Œκ°€ 봀을 λ•Œ Previewλ₯Ό λΉŒλ“œλ₯Ό ν•΄μ„œ 직접 ν™•μΈν•΄λ΄μ•Όν•œλ‹€λŠ” 것을 μ–΄λ–»κ²Œ μ•Œ 수 μžˆμ„κΉŒμš”? πŸ€”
ν˜Ήμ‹œ μƒμ•„λ‹˜κ»˜μ„œ Previewλ₯Ό 봀을 λ•Œ "μ•„ 이건 λΉŒλ“œλ₯Ό ν•΄μ„œ 확인해봐야겠닀" ν•˜λŠ” 기쀀이 μžˆμ„κΉŒμš”?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보톡 μ½”λ“œλ¦¬λ·° ν•  λ•Œ, ν•œλ²ˆμ”© μ‹€ν–‰ ν•΄λ³΄λŠ” νŽΈμž…λ‹ˆλ‹€.
특히 클릭 μΈν„°λž™μ…˜, μ• λ‹ˆλ©”μ΄μ…˜, λ‹€μ΄μ–Όλ‘œκ·Έ, μŠ€λ‚΅λ°”κ°€ μžˆμ„ λ•ŒλŠ” μΈν„°λž™μ…˜μ„ λ³΄κΈ°μœ„ν•΄, λΉŒλ“œλ₯Ό ν•΄λ³΄κ±°λ‚˜, interaction mode λ₯Ό ν™œμš©ν•˜λŠ”λ°μš”.

μ €λŠ” ν•΄λ‹Ή 프리뷰λ₯Ό μƒμ„±ν–ˆμ„ λ•Œ, μ—λŸ¬κ°€ λ°œμƒν–ˆκ³ , λ°œμƒ 이후에 클릭 μ‹œ, μ„±κ³΅μœΌλ‘œ μƒνƒœκ°€ λ³€κ²½λ˜λŠ” 것을 보여주기 μœ„ν•¨μ΄μ—ˆμŠ΅λ‹ˆλ‹€.
이런 생각을 κ°€μ§€κ³  μžˆλ‹€ λ³΄λ‹ˆ λ‹Ήμ—°νžˆ λΉŒλ“œ 해봐야 ν•œλ‹€κ³  μƒκ°ν–ˆλŠ”λ°μš”.

ν˜„μ„λ‹˜μ΄ μ§ˆλ¬Έμ£Όμ‹  λ‚΄μš© 보고 μƒκ°ν•΄λ³΄λ‹ˆ, preview 둜 λ‹¨μˆœ UI λ…ΈμΆœμ„ κΈ°λŒ€ν•˜μ§€, μœ„μ™€ 같은 과정을 κΈ°λŒ€ν• κ±°λΌ μƒκ°λ˜μ§€ μ•Šλ„€μš”.
이런 μΌ€μ΄μŠ€λΌλ©΄, 프리뷰 λ³΄λ‹€λŠ” ViewModel ν…ŒμŠ€νŠΈλ‘œ μ§„ν–‰ν•˜λŠ”κ²Œ 더 적절 ν•˜λ‹€κ³  생각이 λ“€μ—ˆμŠ΅λ‹ˆλ‹€.

λ˜λŠ”, RespoitoryState μƒνƒœμ— λ”°λ₯Έ 화면을 λ³΄μ—¬μ£ΌλŠ” 것이닀 λΌλŠ” κ±Έ ν‘œν˜„ν•  수 μžˆλŠ” λ‹€λ₯Έ 넀이밍이 μ’‹κ² λ‹€ λΌλŠ” 생각도 λ“€μ—ˆμŠ΅λ‹ˆλ‹€.

프리뷰λ₯Ό μ’€ 더 ν”„λ¦¬λ·°λ‘œ ν™œμš©ν•˜κ±°λ‚˜,
λ³΅μž‘ν•œ ν”„λ¦¬λ·°μ˜ 경우 더 λͺ…μ‹œμ μΈ 넀이밍 λ˜λŠ” 상세 μ„€λͺ…이 ν•„μš”ν•˜λ‹€κ³  λŠκΌˆμŠ΅λ‹ˆλ‹€.

λ‹€μ‹œ ν•œ 번 생각 ν•΄λ³Ό 수 있게 질문 μ£Όμ…”μ„œ κ°μ‚¬ν•©λ‹ˆλ‹€.

var repositoryState by remember {
mutableStateOf<GithubRepositoryState.RepositoryUiState>(
GithubRepositoryState.RepositoryUiState.Error()
)
}

GithubTheme {
GithubRepositoryScreen(
repositoryUiState = repositoryState,
showSnackBar = repositoryState is GithubRepositoryState.RepositoryUiState.Error,
onClickSnackBarRetry = {
repositoryState = GithubRepositoryState.RepositoryUiState.Empty
},
)
}
}
55 changes: 46 additions & 9 deletions app/src/main/java/nextstep/github/ui/GithubRepositoryViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nextstep.github.GithubApplication
Expand All @@ -19,33 +19,70 @@ class GithubRepositoryViewModel(
private val githubRepository: GithubRepository,
) : ViewModel() {

private val _githubRepositories = MutableStateFlow<List<GithubRepositoryModel>>(emptyList())
val githubRepositories = _githubRepositories.asStateFlow()
private val _state = GithubRepositoryStateImpl()
val state: GithubRepositoryState = _state

init {
loadRepositories()
}

private fun loadRepositories() {
fun loadRepositories() {
viewModelScope.launch {
githubRepository.getRepositories(NEXT_STEP_ORGANIZATION)
githubRepository.getRepositories(
organization = NEXT_STEP_ORGANIZATION,
onPreLoad = {
_state.repositoryUiState.update { GithubRepositoryState.RepositoryUiState.Loading }
}
)
.onSuccess { data ->
val result = data.mapNotNull { it.toGithubRepositoryModel() }
_githubRepositories.update { result }
_state.repositoryUiState.update {
if (result.isEmpty()) {
GithubRepositoryState.RepositoryUiState.Empty
} else {
GithubRepositoryState.RepositoryUiState.Data(result)
}
}
}
.onError { t ->
_state.repositoryUiState.update {
GithubRepositoryState.RepositoryUiState.Error(t)
}
}
}
}

companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val githubRepository = (this[APPLICATION_KEY] as GithubApplication)
.appContainer
.githubRepository
val githubRepository =
(this[APPLICATION_KEY] as GithubApplication).appContainer.githubRepository
GithubRepositoryViewModel(githubRepository)
}
}
}
}

interface GithubRepositoryState {
val repositoryUiState: StateFlow<RepositoryUiState>

sealed interface RepositoryUiState {
data object Idle : RepositoryUiState
data object Loading : RepositoryUiState
data object Empty : RepositoryUiState
data class Data(val items: List<GithubRepositoryModel>) : RepositoryUiState
data class Error(val t: Throwable? = null) : RepositoryUiState
}
}

class GithubRepositoryStateImpl(
override val repositoryUiState: MutableStateFlow<GithubRepositoryState.RepositoryUiState> = MutableStateFlow(
GithubRepositoryState.RepositoryUiState.Idle
)
) : GithubRepositoryState

sealed interface GithubRepositoryEvent {
data object ShowSnackBar : GithubRepositoryEvent
}

private const val NEXT_STEP_ORGANIZATION = "next-step"
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package nextstep.github.ui.component

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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 nextstep.github.R
import nextstep.github.ui.theme.GithubTheme
import nextstep.github.ui.theme.Typography

@Composable
internal fun GithubRepositoryEmpty(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.github_repositories_empty),
style = Typography.headlineSmall,
)
}
}


@Preview(showBackground = true)
@Composable
private fun GithubRepositoryEmptyPreview() {
GithubTheme {
GithubRepositoryEmpty()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import nextstep.github.ui.theme.GithubTheme

@Composable
internal fun GithubRepositoryList(
model: List<GithubRepositoryModel>, modifier: Modifier = Modifier
model: List<GithubRepositoryModel>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier
) {
modifier = modifier) {
items(
items = model,
key = { it.id }
Expand All @@ -39,7 +39,7 @@ private fun GithubRepositoryListPreview() {
}
}

private fun dummyList() = buildList {
internal fun dummyList() = buildList {
for (index in 0..30) {
add(
GithubRepositoryModel(
Expand Down
Loading