From ac433bc4923177f02a36f2a93939df3525f0b1d8 Mon Sep 17 00:00:00 2001 From: Shcherbatykh Oleg Date: Fri, 27 Feb 2026 10:58:29 +0300 Subject: [PATCH] Start impl base news ui --- .../news/presentation/navigation/NewsGraph.kt | 20 +++ .../news/presentation/screen/NewsIntent.kt | 10 ++ .../news/presentation/screen/NewsRoute.kt | 29 ++++ .../news/presentation/screen/NewsScreen.kt | 150 ++++++++++++++++++ .../news/presentation/screen/NewsUiState.kt | 11 ++ .../news/presentation/screen/NewsViewModel.kt | 38 ++++- 6 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/navigation/NewsGraph.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsIntent.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsRoute.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsScreen.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsUiState.kt diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/navigation/NewsGraph.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/navigation/NewsGraph.kt new file mode 100644 index 0000000..8e0b5cd --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/navigation/NewsGraph.kt @@ -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 { + NewsRoute(modifier = modifier) + } + +// composable { entry -> +// val args = entry.toRoute() +// NewsDetailsRoute(id = args.id) +// } +} diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsIntent.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsIntent.kt new file mode 100644 index 0000000..91bc6d8 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsIntent.kt @@ -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 +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsRoute.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsRoute.kt new file mode 100644 index 0000000..29b72ef --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsRoute.kt @@ -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 + ) +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsScreen.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsScreen.kt new file mode 100644 index 0000000..99b044f --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsScreen.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsUiState.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsUiState.kt new file mode 100644 index 0000000..8d8b78e --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsUiState.kt @@ -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 = emptyList(), + val isRefreshing: Boolean = false, + val isAppending: Boolean = false, + val endReached: Boolean = false, + val errorMessage: String? = null +) \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsViewModel.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsViewModel.kt index ad7c7ee..4badd3c 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsViewModel.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsViewModel.kt @@ -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 = 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() - } } \ No newline at end of file