1 Commits

Author SHA1 Message Date
Shcherbatykh Oleg
06fa00eb8f Start impl base news ui 2026-03-02 11:27:28 +03:00
30 changed files with 497 additions and 466 deletions

View File

@@ -53,11 +53,13 @@ kapt {
dependencies {
// Compose
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
// Navigation Compose
implementation(libs.androidx.navigation.compose)

View File

@@ -2,12 +2,16 @@ package ru.fincode.tsudesk.presentation.main
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CalendarToday
import androidx.compose.material.icons.rounded.Newspaper
import androidx.compose.material.icons.rounded.School
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import ru.fincode.tsudesk.R
@@ -21,14 +25,10 @@ fun MainScaffold(
navController: NavHostController,
content: @Composable (Modifier) -> Unit,
) {
val scheduleIcon =
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_progress)
val newsIcon =
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_progress)
val progressIcon =
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_progress)
val settingsIcon =
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_progress)
val scheduleIcon = Icons.Rounded.CalendarToday
val newsIcon = Icons.Rounded.Newspaper
val progressIcon = Icons.Rounded.School
val settingsIcon = Icons.Rounded.Settings
val items = listOf(
TopLevelItem(TopLevelDestination.SCHEDULE, R.string.tab_schedule, scheduleIcon),
@@ -41,10 +41,7 @@ fun MainScaffold(
val selected = selectedTopLevel(backStackEntry)
Scaffold(
// ВАЖНО: при edge-to-edge отключаем автоматические systemBars insets у Scaffold,
// иначе получаем двойные отступы (Scaffold + windowInsetsPadding в Header'е)
contentWindowInsets = WindowInsets(0),
bottomBar = {
if (shouldShowBottomBar(backStackEntry)) {
val uiItems = items.map { item ->
@@ -68,8 +65,6 @@ fun MainScaffold(
}
}
) { innerPadding ->
// innerPadding учитывает bottomBar и системные элементы Scaffold (если будут),
// но НЕ добавляет statusBars (мы делаем это вручную в нужных местах, например в Header)
content(Modifier.padding(innerPadding))
}
}

View File

@@ -37,7 +37,7 @@ android {
dependencies {
implementation(libs.kotlinx.serialization.json)
// Compose
implementation(platform(libs.compose.bom))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.runtime)
// Navigation Compose
implementation(libs.androidx.navigation.compose)

View File

@@ -44,7 +44,7 @@ dependencies {
// Compose UI
implementation(platform(libs.compose.bom))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)

View File

@@ -3,5 +3,5 @@ package ru.fincode.tsudesk.core.ui.theme
import androidx.compose.ui.graphics.Color
// Brand (ТулГУ / TSUDesk)
val BrandRed = Color(0xFF7C1D1D) // замени на точный бордовый
val BrandRed = Color(0xFF8B1A1A)
val OnBrand = Color(0xFFFFFFFF)

View File

@@ -4,19 +4,23 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
val LightColorScheme = lightColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF6750A4),
onPrimary = androidx.compose.ui.graphics.Color.White,
primary = BrandRed,
onPrimary = OnBrand,
secondary = androidx.compose.ui.graphics.Color(0xFF625B71),
onSecondary = androidx.compose.ui.graphics.Color.White,
tertiary = androidx.compose.ui.graphics.Color(0xFF7D5260),
onTertiary = androidx.compose.ui.graphics.Color.White,
)
val DarkColorScheme = darkColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFFD0BCFF),
onPrimary = androidx.compose.ui.graphics.Color(0xFF381E72),
primary = BrandRed,
onPrimary = OnBrand,
secondary = androidx.compose.ui.graphics.Color(0xFFCCC2DC),
onSecondary = androidx.compose.ui.graphics.Color(0xFF332D41),
tertiary = androidx.compose.ui.graphics.Color(0xFFEFB8C8),
onTertiary = androidx.compose.ui.graphics.Color(0xFF492532),
)

View File

@@ -7,6 +7,9 @@ data class ExtendedColors(
val brand: Color,
val onBrand: Color,
val brandSoft: Color,
val brandGradientStart: Color,
val brandGradientEnd: Color,
val borderSoft: Color,
)
val LocalExtendedColors = staticCompositionLocalOf {
@@ -14,5 +17,8 @@ val LocalExtendedColors = staticCompositionLocalOf {
brand = Color.Unspecified,
onBrand = Color.Unspecified,
brandSoft = Color.Unspecified,
brandGradientStart = Color.Unspecified,
brandGradientEnd = Color.Unspecified,
borderSoft = Color.Unspecified,
)
}

View File

@@ -16,6 +16,12 @@ fun TSUDeskTheme(
brand = BrandRed,
onBrand = OnBrand,
brandSoft = BrandRed.copy(alpha = 0.12f),
brandGradientStart = BrandRed.copy(alpha = 0.95f),
brandGradientEnd = BrandRed,
borderSoft = if (darkTheme)
scheme.outline.copy(alpha = 0.4f)
else
scheme.outline.copy(alpha = 0.2f),
)
CompositionLocalProvider(LocalExtendedColors provides extended) {

View File

@@ -41,25 +41,33 @@ kapt {
correctErrorTypes = true
}
dependencies {
// Android
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.google.material)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.androidx.paging.runtime)
// Paging
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
// Compose
implementation(platform(libs.compose.bom))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
// Coil
implementation(libs.coil.compose)
// Navigation Compose
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.common)
// Hilt
kapt(libs.hilt.compiler)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(projects.core.network)
implementation(projects.core.config)

View File

@@ -1,28 +1,27 @@
package ru.fincode.tsudesk.feature.news.data
import kotlinx.coroutines.Dispatchers
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.feature.news.data.remote.datasource.NewsRemoteDataSource
import ru.fincode.tsudesk.feature.news.data.remote.parser.NewsHtmlParser
import ru.fincode.tsudesk.feature.news.data.datasource.NewsArchiveDataSource
import ru.fincode.tsudesk.feature.news.data.paging.NewsArchivePagingSource
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
import javax.inject.Inject
class NewsRepositoryImpl @Inject constructor(
private val remote: NewsRemoteDataSource,
private val parser: NewsHtmlParser
private val dataSource: NewsArchiveDataSource
) : NewsRepository {
override fun loadPage(page: Int): Flow<NetworkResult<List<NewsItem>>> = flow {
when (val result = remote.loadArchivePageHtml(page)) {
is NetworkResult.Success -> {
val items = parser.parseArchivePage(result.data)
emit(NetworkResult.Success(items, result.meta))
}
is NetworkResult.Error -> emit(result)
}
}.flowOn(Dispatchers.IO)
override fun paging(): Flow<PagingData<NewsItem>> =
Pager(
config = PagingConfig(
pageSize = 20,
initialLoadSize = 20,
prefetchDistance = 2,
enablePlaceholders = false
),
pagingSourceFactory = { NewsArchivePagingSource(dataSource) }
).flow
}

View File

@@ -0,0 +1,36 @@
package ru.fincode.tsudesk.feature.news.data.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.feature.news.data.datasource.NewsArchiveDataSource
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
class NewsArchivePagingSource(
private val dataSource: NewsArchiveDataSource
) : PagingSource<Int, NewsItem>() {
override fun getRefreshKey(state: PagingState<Int, NewsItem>): Int? {
val anchor = state.anchorPosition ?: return null
val page = state.closestPageToPosition(anchor)
return page?.prevKey?.plus(1) ?: page?.nextKey?.minus(1)
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NewsItem> {
val page = params.key ?: 1
return when (val res = dataSource.loadPage(page)) {
is NetworkResult.Success -> {
val parsed = res.data
LoadResult.Page(
data = parsed.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (parsed.hasNextPage) page + 1 else null
)
}
is NetworkResult.Error -> {
LoadResult.Error(NewsPagingException(res.error))
}
}
}
}

View File

@@ -0,0 +1,7 @@
package ru.fincode.tsudesk.feature.news.data.paging
import ru.fincode.tsudesk.core.network.model.NetworkError
class NewsPagingException(
val networkError: NetworkError
) : RuntimeException(networkError.toString())

View File

@@ -1,4 +1,4 @@
package ru.fincode.tsudesk.feature.news.data.remote
package ru.fincode.tsudesk.feature.news.data.remote.api
import okhttp3.ResponseBody
import retrofit2.Response
@@ -7,8 +7,7 @@ import retrofit2.http.Query
interface NewsApi {
// https://tulsu.ru/news/all?page=534
@GET("news/all")
@GET("press-releases/university")
suspend fun getNewsArchive(
@Query("page") page: Int,
): Response<ResponseBody>

View File

@@ -1,19 +1,25 @@
package ru.fincode.tsudesk.feature.news.data.remote.datasource
package ru.fincode.tsudesk.feature.news.data.datasource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fincode.tsudesk.core.network.apiCall
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.model.map
import ru.fincode.tsudesk.feature.news.data.remote.NewsApi
import ru.fincode.tsudesk.feature.news.data.parser.NewsArchiveHtmlParser
import ru.fincode.tsudesk.feature.news.data.remote.api.NewsApi
import javax.inject.Inject
class NewsRemoteDataSource @Inject constructor(
private val api: NewsApi
class NewsArchiveDataSource @Inject constructor(
private val api: NewsApi,
private val parser: NewsArchiveHtmlParser
) {
suspend fun loadArchivePageHtml(page: Int): NetworkResult<String> =
suspend fun loadPage(page: Int): NetworkResult<NewsArchiveHtmlParser.ParsedPage> =
withContext(Dispatchers.IO) {
apiCall { api.getNewsArchive(page = page) }
.map { body, _ -> body.string() }
.map { body, _ ->
val html = body.string()
parser.parse(html = html, currentPage = page)
}
}
}

View File

@@ -0,0 +1,74 @@
package ru.fincode.tsudesk.feature.news.data.parser
import org.jsoup.Jsoup
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
import javax.inject.Inject
/**
* Чистый парсер HTML архива:
* - без baseUrl/baseUri
* - без https://...
* - парсит grid карточек из view-source
*/
class NewsArchiveHtmlParser @Inject constructor() {
data class ParsedPage(
val items: List<NewsItem>,
val hasNextPage: Boolean
)
fun parse(html: String, currentPage: Int): ParsedPage {
val doc = Jsoup.parse(html)
val cards = doc.select("main#press-releases-content a.card.card-ghost[href*=/press-releases/]")
val items = cards.mapNotNull { card ->
val href = card.attr("href").trim()
if (href.isBlank() || !href.contains("/press-releases/")) return@mapNotNull null
val id = href.substringAfterLast('/').takeWhile { it.isDigit() }
if (id.isBlank()) return@mapNotNull null
val title = card.selectFirst("h6.card-title")?.text()?.trim().orEmpty()
if (title.isBlank()) return@mapNotNull null
val metaText = card.selectFirst("p.card-text")?.text()?.trim().orEmpty()
val dateText = extractDate(metaText) ?: return@mapNotNull null
val tags = extractTags(metaText)
val category = tags.firstOrNull() ?: "Новости"
val imageUrl = card.selectFirst("img.card-img[src]")
?.attr("src")
?.trim()
?.takeIf { it.isNotBlank() }
NewsItem(
id = id,
title = title,
dateText = dateText,
imageUrl = imageUrl,
category = category,
url = href
)
}.distinctBy { it.id }
val hasNextPage = doc.select("a[href*=\"page=${currentPage + 1}\"]").isNotEmpty()
return ParsedPage(items = items, hasNextPage = hasNextPage)
}
private fun extractDate(text: String): String? {
val regex = Regex("""\b\d{2}\.\d{2}\.\d{4}(?:\s+\d{2}:\d{2})?\b""")
return regex.find(text)?.value
}
private fun extractTags(text: String): List<String> {
val date = extractDate(text) ?: return emptyList()
val afterDate = text.substringAfter(date).trim()
return afterDate
.split("/")
.map { it.trim() }
.filter { it.isNotBlank() }
}
}

View File

@@ -1,89 +0,0 @@
package ru.fincode.tsudesk.feature.news.data.remote.parser
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.Locale
class NewsHtmlParser {
private val dateFormatter =
DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.forLanguageTag("ru"))
fun parseArchivePage(html: String): List<NewsItem> {
val itemRegex = Regex(
pattern = """<div class="single-blog">([\s\S]*?)</div>\s*</div>\s*</div>""",
options = setOf(RegexOption.IGNORE_CASE)
)
return itemRegex.findAll(html).mapNotNull { match ->
val block = match.value
val dateString = findFirst(
block,
"""<span>\s*(\d{2}\.\d{2}\.\d{4})\s*</span>"""
) ?: return@mapNotNull null
val date = parseDate(dateString) ?: return@mapNotNull null
val category = findFirst(
block,
"""</ul>\s*<span>\s*([^<]+?)\s*</span>"""
)?.trim().orEmpty()
val (url, title) = findLinkAndTitle(block) ?: return@mapNotNull null
val id = extractIdFromUrl(url) ?: return@mapNotNull null
NewsItem(
id = id,
title = htmlUnescape(title.trim()),
url = url.trim(),
date = date,
category = htmlUnescape(category.trim())
)
}.toList()
}
private fun parseDate(raw: String): LocalDate? =
try {
LocalDate.parse(raw.trim(), dateFormatter)
} catch (_: DateTimeParseException) {
null
}
private fun findLinkAndTitle(block: String): Pair<String, String>? {
val linkRegex = Regex(
pattern = """<h3>\s*<a\s+href="([^"]+)">\s*([\s\S]*?)\s*</a>""",
options = setOf(RegexOption.IGNORE_CASE)
)
val m = linkRegex.find(block) ?: return null
val url = m.groupValues[1]
val title = stripTags(m.groupValues[2])
return url to title
}
private fun extractIdFromUrl(url: String): Long? {
val idRegex = Regex(""".*/news/all/(\d+).*""")
val m = idRegex.matchEntire(url) ?: return null
return m.groupValues[1].toLongOrNull()
}
private fun findFirst(text: String, pattern: String): String? =
Regex(pattern, RegexOption.IGNORE_CASE)
.find(text)
?.groupValues
?.getOrNull(1)
private fun stripTags(s: String): String =
s.replace(Regex("<[^>]*>"), " ")
.replace(Regex("\\s+"), " ")
.trim()
private fun htmlUnescape(s: String): String =
s.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#39;", "'")
}

View File

@@ -6,8 +6,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import ru.fincode.tsudesk.feature.news.data.NewsRepositoryImpl
import ru.fincode.tsudesk.feature.news.data.remote.NewsApi
import ru.fincode.tsudesk.feature.news.data.remote.parser.NewsHtmlParser
import ru.fincode.tsudesk.feature.news.data.remote.api.NewsApi
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
import javax.inject.Singleton
@@ -20,10 +19,6 @@ object NewsModule {
fun provideNewsApi(retrofit: Retrofit): NewsApi =
retrofit.create(NewsApi::class.java)
@Provides
@Singleton
fun provideNewsHtmlParser(): NewsHtmlParser = NewsHtmlParser()
@Provides
@Singleton
fun provideNewsRepository(impl: NewsRepositoryImpl): NewsRepository = impl

View File

@@ -1,11 +1,11 @@
package ru.fincode.tsudesk.feature.news.domain.model
import java.time.LocalDate
data class NewsItem(
val id: Long,
val id: String,
val title: String,
val url: String,
val date: LocalDate,
val category: String
val dateText: String,
val imageUrl: String?,
val category: String,
val excerpt: String? = null,
val url: String
)

View File

@@ -1,9 +1,9 @@
package ru.fincode.tsudesk.feature.news.domain.repository
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
interface NewsRepository {
fun loadPage(page: Int): Flow<NetworkResult<List<NewsItem>>>
fun paging(): Flow<PagingData<NewsItem>>
}

View File

@@ -0,0 +1,44 @@
package ru.fincode.tsudesk.feature.news.domain.usecase
import androidx.paging.PagingData
import androidx.paging.filter
import androidx.paging.cachedIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
import javax.inject.Inject
class GetNewsPagingUseCase @Inject constructor(
private val repository: NewsRepository
) {
/**
* @param queryFlow поток поискового запроса
* @param scope scope для cachedIn
*/
operator fun invoke(
queryFlow: Flow<String>,
scope: CoroutineScope
): Flow<PagingData<NewsItem>> {
val pagingBase = repository
.paging()
.cachedIn(scope)
val debouncedQuery = queryFlow
.map(String::trim)
.debounce(300)
.distinctUntilChanged()
return combine(pagingBase, debouncedQuery) { paging, query ->
if (query.isBlank()) {
paging
} else {
paging.filter {
it.title.contains(query, ignoreCase = true)
}
}
}
}
}

View File

@@ -1,106 +0,0 @@
package ru.fincode.tsudesk.feature.news.domain.usecase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.feature.news.domain.model.NewsFeedState
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
import javax.inject.Inject
class NewsInfinityFeedUseCase @Inject constructor(
private val repo: NewsRepository
) {
private val actions = MutableSharedFlow<Action>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _state = MutableStateFlow(NewsFeedState())
val state: StateFlow<NewsFeedState> = _state.asStateFlow()
fun attach(scope: CoroutineScope) {
scope.launch {
actions.collect { action ->
when (action) {
Action.Refresh -> doRefresh()
Action.LoadNext -> doLoadNext()
}
}
}
}
fun refresh() {
actions.tryEmit(Action.Refresh)
}
fun loadNext() {
actions.tryEmit(Action.LoadNext)
}
private suspend fun doRefresh() {
_state.value =
NewsFeedState(isLoading = true, nextPage = 1, endReached = false, lastError = null)
loadPageIntoState(page = 1, replace = true)
}
private suspend fun doLoadNext() {
val s = _state.value
if (s.isLoading || s.endReached) return
_state.update { it.copy(isLoading = true, lastError = null) }
loadPageIntoState(page = s.nextPage, replace = false)
}
private suspend fun loadPageIntoState(page: Int, replace: Boolean) {
repo.loadPage(page).collect { result ->
when (result) {
is NetworkResult.Success -> {
val incoming = result.data
_state.update { current ->
val merged = if (replace) {
incoming
} else {
mergeUnique(current.items, incoming)
}
current.copy(
items = merged,
isLoading = false,
nextPage = page + 1,
endReached = incoming.isEmpty(),
lastError = null
)
}
}
is NetworkResult.Error -> {
_state.update { current ->
current.copy(
isLoading = false,
lastError = result.error
)
}
}
}
}
}
private fun mergeUnique(existing: List<NewsItem>, incoming: List<NewsItem>): List<NewsItem> {
if (incoming.isEmpty()) return existing
val seen = existing.asSequence().map { it.id }.toHashSet()
val add = incoming.filter { seen.add(it.id) }
return existing + add
}
private sealed interface Action {
data object Refresh : Action
data object LoadNext : Action
}
}

View File

@@ -9,12 +9,17 @@ import ru.fincode.tsudesk.feature.news.presentation.screen.NewsRoute
fun NavGraphBuilder.newsGraph(
modifier: Modifier = Modifier,
) {
composable<AppRoute.News> {
NewsRoute(modifier = modifier)
NewsRoute(
modifier = modifier
// onOpenDetails = onOpenDetails
)
}
// composable<AppRoute.NewsDetails> { entry ->
// val args = entry.toRoute<AppRoute.NewsDetails>()
// NewsDetailsRoute(id = args.id)
// }
// Если позже сделаешь экран деталей — добавишь сюда:
// composable<AppRoute.NewsDetails> { entry ->
// val args = entry.toRoute<AppRoute.NewsDetails>()
// NewsDetailsRoute(url = args.url)
// }
}

View File

@@ -0,0 +1,6 @@
package ru.fincode.tsudesk.feature.news.presentation.screen
sealed interface NewsEvent {
data class QueryChanged(val value: String) : NewsEvent
data object ClearQuery : NewsEvent
}

View File

@@ -1,29 +1,17 @@
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(),
modifier: Modifier = Modifier
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val viewModel: NewsViewModel = hiltViewModel()
NewsScreen(
state = state,
onIntent = { intent ->
when (intent) {
is NewsIntent.Open -> onOpen?.invoke(intent.item)
else -> viewModel.onIntent(intent)
}
},
onScrolledToEnd = viewModel::onScrolledToEnd,
modifier = modifier
modifier = modifier,
viewModel = viewModel
)
}

View File

@@ -1,150 +1,201 @@
package ru.fincode.tsudesk.feature.news.presentation.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import java.time.format.DateTimeFormatter
import androidx.paging.compose.collectAsLazyPagingItems
import coil.compose.AsyncImage
@Composable
fun NewsScreen(
state: NewsUiState,
onIntent: (NewsIntent) -> Unit,
onScrolledToEnd: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NewsViewModel
) {
// Пусто + 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")
val state by viewModel.state.collectAsState()
val pagingItems = viewModel.newsPaging.collectAsLazyPagingItems()
val colors = MaterialTheme.colorScheme
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
modifier = Modifier
.fillMaxSize()
.background(colors.surface),
contentPadding = PaddingValues(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(16.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()
item {
NewsHeader(
query = state.query,
onQueryChange = {
viewModel.onEvent(NewsEvent.QueryChanged(it))
}
}
)
}
// Footer: догрузка
item {
if (state.isAppending) {
Column(modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator()
items(
count = pagingItems.itemCount,
key = { index -> pagingItems[index]?.id ?: "placeholder_$index" }
) { index ->
val item = pagingItems[index] ?: return@items
NewsCard(
title = item.title,
dateText = item.dateText,
category = item.category,
imageUrl = item.imageUrl,
onClick = { //TODO: open news details
}
} 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,
private fun NewsHeader(
query: String,
onQueryChange: (String) -> Unit
) {
val colors = MaterialTheme.colorScheme
Column(
modifier = Modifier
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
.fillMaxWidth()
.background(
colors.primaryContainer,
shape = RoundedCornerShape(
bottomStart = 40.dp,
bottomEnd = 40.dp
)
)
.padding(20.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
"Новости",
style = MaterialTheme.typography.headlineLarge,
color = colors.onPrimaryContainer
)
Spacer(Modifier.height(6.dp))
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp)
"ТулГУ · Последние обновления",
style = MaterialTheme.typography.bodyMedium,
color = colors.onPrimaryContainer.copy(alpha = 0.8f)
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("Поиск новостей...") },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = colors.outline,
unfocusedBorderColor = colors.outline.copy(alpha = 0.5f),
focusedContainerColor = colors.surfaceContainer,
unfocusedContainerColor = colors.surfaceContainer
),
shape = RoundedCornerShape(20.dp)
)
}
}
@Composable
private fun NewsCard(
title: String,
dateText: String,
category: String,
imageUrl: String?,
onClick: () -> Unit
) {
val colors = MaterialTheme.colorScheme
Card(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
onClick = onClick,
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(
containerColor = colors.surfaceContainer
)
) {
Column {
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(190.dp)
)
}
Column(Modifier.padding(20.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
color = colors.primaryContainer,
shape = RoundedCornerShape(12.dp)
) {
Text(
category.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = colors.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
Text(
dateText,
style = MaterialTheme.typography.labelMedium,
color = colors.onSurfaceVariant
)
}
Spacer(Modifier.height(12.dp))
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = colors.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,11 +1,5 @@
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
val query: String = ""
)

View File

@@ -2,55 +2,39 @@ package ru.fincode.tsudesk.feature.news.presentation.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import ru.fincode.tsudesk.feature.news.domain.usecase.NewsInfinityFeedUseCase
import kotlinx.coroutines.flow.update
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
import ru.fincode.tsudesk.feature.news.domain.usecase.GetNewsPagingUseCase
import javax.inject.Inject
@HiltViewModel
class NewsViewModel @Inject constructor(
private val feed: NewsInfinityFeedUseCase
private val getNewsPagingUseCase: GetNewsPagingUseCase
) : ViewModel() {
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)
private val _state = MutableStateFlow(NewsUiState())
val state: StateFlow<NewsUiState> = _state.asStateFlow()
val newsPaging: Flow<PagingData<NewsItem>> =
getNewsPagingUseCase(
queryFlow = state.map { it.query },
scope = viewModelScope
)
init {
feed.attach(viewModelScope)
feed.refresh()
}
fun onEvent(event: NewsEvent) {
when (event) {
is NewsEvent.QueryChanged ->
_state.update { it.copy(query = event.value) }
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
}
NewsEvent.ClearQuery ->
_state.update { it.copy(query = "") }
}
}
fun onScrolledToEnd() {
feed.loadNext()
}
}

View File

@@ -48,7 +48,7 @@ dependencies {
implementation(libs.kotlinx.collections.immutable)
// Compose UI
implementation(platform(libs.compose.bom))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
@@ -60,7 +60,7 @@ dependencies {
// DI
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
// Core modules
implementation(projects.core.common)

View File

@@ -41,10 +41,10 @@ kapt {
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.google.material)
// Compose
implementation(platform(libs.compose.bom))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
@@ -56,7 +56,7 @@ dependencies {
kapt(libs.hilt.compiler)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(projects.core.network)
implementation(projects.core.common)

View File

@@ -1,4 +1,5 @@
[versions]
# SDK / build
compileSdk = "36"
minSdk = "26"
targetSdk = "36"
@@ -8,37 +9,38 @@ versionCode = "1"
agp = "8.12.0"
kotlin = "1.9.24"
jvmTarget = "17"
kotlinCompilerExtension = "1.5.14"
# KotlinX
coroutines = "1.8.1"
serialization = "1.6.3"
kotlinxImmutable = "0.3.8"
kotlinCompilerExtension = "1.5.14"
# AndroidX / Google
coreKtx = "1.10.1"
appcompat = "1.6.1"
material = "1.10.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
compose-bom = "2024.10.00"
hilt-nav-compose = "1.2.0"
activityCompose = "1.8.0"
navigation = "2.8.5"
paging = "3.3.2"
lifecycle = "2.7.0"
composeBom = "2024.10.00"
material = "1.13.0"
# DI
hilt = "2.50"
hiltNavCompose = "1.2.0"
# Network
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"
jsoup = "1.17.2"
# DB
room = "2.6.1"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
materialVersion = "1.13.0"
junitJunit = "4.13.2"
androidxJunit = "1.3.0"
espressoCoreVersion = "3.7.0"
# Images
coil = "2.7.0"
[libraries]
# KotlinX
@@ -46,23 +48,36 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
# AndroidX
# AndroidX base
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
# Compose (BOM)
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-material3 = { module = "androidx.compose.material3:material3" }
# Navigation / Hilt
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
# Material (Views / common)
google-material = { module = "com.google.android.material:material", version.ref = "material" }
# Navigation
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-navigation-common = { module = "androidx.navigation:navigation-common-ktx", version.ref = "navigation" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-nav-compose" }
# DI (Hilt)
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavCompose" }
# Paging
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
# Network
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
@@ -72,17 +87,19 @@ retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
# Room
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
# Images
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
# Debug
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" }
junit = { group = "junit", name = "junit", version.ref = "junitJunit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCoreVersion" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }