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

πŸš€ 3단계 - GitHub(UI μƒνƒœ) #52

Merged
merged 9 commits into from
Mar 13, 2025
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