Start impl base news ui
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package ru.fincode.tsudesk.feature.news.presentation.navigation
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import ru.fincode.tsudesk.core.navigation.AppRoute
|
||||
import ru.fincode.tsudesk.feature.news.presentation.screen.NewsRoute
|
||||
|
||||
fun NavGraphBuilder.newsGraph(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
composable<AppRoute.News> {
|
||||
NewsRoute(modifier = modifier)
|
||||
}
|
||||
|
||||
// composable<AppRoute.NewsDetails> { entry ->
|
||||
// val args = entry.toRoute<AppRoute.NewsDetails>()
|
||||
// NewsDetailsRoute(id = args.id)
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
|
||||
sealed interface NewsIntent {
|
||||
data object Refresh : NewsIntent
|
||||
data object LoadNext : NewsIntent
|
||||
data object Retry : NewsIntent
|
||||
data class Open(val item: NewsItem) : NewsIntent
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
|
||||
@Composable
|
||||
fun NewsRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpen: ((NewsItem) -> Unit)? = null,
|
||||
viewModel: NewsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
NewsScreen(
|
||||
state = state,
|
||||
onIntent = { intent ->
|
||||
when (intent) {
|
||||
is NewsIntent.Open -> onOpen?.invoke(intent.item)
|
||||
else -> viewModel.onIntent(intent)
|
||||
}
|
||||
},
|
||||
onScrolledToEnd = viewModel::onScrolledToEnd,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.itemsIndexed
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun NewsScreen(
|
||||
state: NewsUiState,
|
||||
onIntent: (NewsIntent) -> Unit,
|
||||
onScrolledToEnd: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Пусто + refreshing: показываем “загрузка”
|
||||
if (state.items.isEmpty() && state.isRefreshing) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = "Загрузка новостей…",
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Пусто + ошибка: показываем “повторить”
|
||||
if (state.items.isEmpty() && state.errorMessage != null) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Не удалось загрузить новости",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = state.errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Button(
|
||||
onClick = { onIntent(NewsIntent.Refresh) },
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val dateFmt = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = state.items,
|
||||
key = { _, item -> item.id }
|
||||
) { index, item ->
|
||||
NewsRow(
|
||||
title = item.title,
|
||||
meta = "${item.category} • ${item.date.format(dateFmt)}",
|
||||
onClick = { onIntent(NewsIntent.Open(item)) }
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Триггер догрузки: подошли к концу списка
|
||||
val shouldLoadNext =
|
||||
index >= state.items.lastIndex - 2 &&
|
||||
!state.isAppending &&
|
||||
!state.endReached
|
||||
|
||||
if (shouldLoadNext) {
|
||||
LaunchedEffect(item.id) {
|
||||
onScrolledToEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: догрузка
|
||||
item {
|
||||
if (state.isAppending) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (state.errorMessage != null) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Ошибка догрузки: ${state.errorMessage}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Button(
|
||||
onClick = { onIntent(NewsIntent.Retry) },
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewsRow(
|
||||
title: String,
|
||||
meta: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
|
||||
data class NewsUiState(
|
||||
val items: List<NewsItem> = emptyList(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val isAppending: Boolean = false,
|
||||
val endReached: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
@@ -3,6 +3,10 @@ package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import ru.fincode.tsudesk.feature.news.domain.usecase.NewsInfinityFeedUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -11,18 +15,42 @@ class NewsViewModel @Inject constructor(
|
||||
private val feed: NewsInfinityFeedUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val state = feed.state
|
||||
val uiState: StateFlow<NewsUiState> = feed.state
|
||||
.map { s ->
|
||||
NewsUiState(
|
||||
items = s.items,
|
||||
// минимально: считаем, что "refreshing" — это page=1 и loading
|
||||
isRefreshing = s.isLoading && s.nextPage == 1,
|
||||
// "appending" — loading после первой страницы
|
||||
isAppending = s.isLoading && s.nextPage > 1,
|
||||
endReached = s.endReached,
|
||||
errorMessage = s.lastError?.toString()
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = NewsUiState(isRefreshing = true)
|
||||
)
|
||||
|
||||
init {
|
||||
feed.attach(viewModelScope)
|
||||
feed.refresh()
|
||||
}
|
||||
|
||||
fun onIntent(intent: NewsIntent) {
|
||||
when (intent) {
|
||||
NewsIntent.Refresh -> feed.refresh()
|
||||
NewsIntent.LoadNext -> feed.loadNext()
|
||||
NewsIntent.Retry -> feed.loadNext()
|
||||
is NewsIntent.Open -> {
|
||||
// навигацию лучше делать через callback из Route (см ниже),
|
||||
// здесь пока no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onScrolledToEnd() {
|
||||
feed.loadNext()
|
||||
}
|
||||
|
||||
fun onRetry() {
|
||||
feed.loadNext()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user