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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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 ru.fincode.tsudesk.feature.news.domain.usecase.NewsInfinityFeedUseCase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -11,18 +15,42 @@ class NewsViewModel @Inject constructor(
|
|||||||
private val feed: NewsInfinityFeedUseCase
|
private val feed: NewsInfinityFeedUseCase
|
||||||
) : ViewModel() {
|
) : 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 {
|
init {
|
||||||
feed.attach(viewModelScope)
|
feed.attach(viewModelScope)
|
||||||
feed.refresh()
|
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() {
|
fun onScrolledToEnd() {
|
||||||
feed.loadNext()
|
feed.loadNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onRetry() {
|
|
||||||
feed.loadNext()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user