Start impl base news ui

This commit is contained in:
Shcherbatykh Oleg
2026-02-27 10:58:29 +03:00
parent 2fc7f40677
commit ac433bc492
6 changed files with 253 additions and 5 deletions

View File

@@ -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)
// }
}

View File

@@ -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
}

View File

@@ -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
)
}

View File

@@ -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)
)
}
}

View File

@@ -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
)

View File

@@ -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()
}
} }