From 06fa00eb8ff392d9ed1b456c5bddd16884970e7d Mon Sep 17 00:00:00 2001 From: Shcherbatykh Oleg Date: Mon, 2 Mar 2026 11:27:28 +0300 Subject: [PATCH] Start impl base news ui --- app/build.gradle.kts | 4 +- .../tsudesk/presentation/main/MainScaffold.kt | 23 +- core/navigation/build.gradle.kts | 2 +- core/ui/build.gradle.kts | 2 +- .../ru/fincode/tsudesk/core/ui/theme/Color.kt | 2 +- .../tsudesk/core/ui/theme/ColorScheme.kt | 12 +- .../tsudesk/core/ui/theme/ExtendedColors.kt | 6 + .../tsudesk/core/ui/theme/TSUDeskTheme.kt | 6 + feature/news/build.gradle.kts | 16 +- .../feature/news/data/NewsRepositoryImpl.kt | 33 ++- .../data/paging/NewsArchivePagingSource.kt | 36 +++ .../news/data/paging/NewsPagingException.kt | 7 + .../news/data/remote/{ => api}/NewsApi.kt | 5 +- .../remote/datasource/NewsRemoteDataSource.kt | 18 +- .../remote/parser/NewsArchiveHtmlParser.kt | 74 +++++ .../news/data/remote/parser/NewsHtmlParser.kt | 89 ------ .../tsudesk/feature/news/di/NewsModule.kt | 7 +- .../feature/news/domain/model/NewsItem.kt | 12 +- .../news/domain/repository/NewsRepository.kt | 4 +- .../domain/usecase/GetNewsPagingUseCase.kt | 44 +++ .../domain/usecase/NewsInfinityFeedUseCase.kt | 106 ------- .../news/presentation/navigation/NewsGraph.kt | 17 +- .../news/presentation/screen/NewsEvent.kt | 6 + .../news/presentation/screen/NewsRoute.kt | 20 +- .../news/presentation/screen/NewsScreen.kt | 269 +++++++++++------- .../news/presentation/screen/NewsUiState.kt | 8 +- .../news/presentation/screen/NewsViewModel.kt | 58 ++-- feature/schedule/build.gradle.kts | 4 +- feature/splash/build.gradle.kts | 6 +- gradle/libs.versions.toml | 67 +++-- 30 files changed, 497 insertions(+), 466 deletions(-) create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsArchivePagingSource.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsPagingException.kt rename feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/{ => api}/NewsApi.kt (65%) create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsArchiveHtmlParser.kt delete mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsHtmlParser.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/GetNewsPagingUseCase.kt delete mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/NewsInfinityFeedUseCase.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsEvent.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 735a48a..e1e0f9a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/ru/fincode/tsudesk/presentation/main/MainScaffold.kt b/app/src/main/java/ru/fincode/tsudesk/presentation/main/MainScaffold.kt index ebe9a6d..7bd6f33 100644 --- a/app/src/main/java/ru/fincode/tsudesk/presentation/main/MainScaffold.kt +++ b/app/src/main/java/ru/fincode/tsudesk/presentation/main/MainScaffold.kt @@ -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)) } } \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index acdd578..b01e6a5 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -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) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c4b91cc..d417912 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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) diff --git a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/Color.kt b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/Color.kt index f99ad52..8c2b19e 100644 --- a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/Color.kt +++ b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ColorScheme.kt b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ColorScheme.kt index 706394e..5eea4bc 100644 --- a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ColorScheme.kt +++ b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ColorScheme.kt @@ -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), ) \ No newline at end of file diff --git a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ExtendedColors.kt b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ExtendedColors.kt index f93d082..cb84704 100644 --- a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ExtendedColors.kt +++ b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/ExtendedColors.kt @@ -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, ) } \ No newline at end of file diff --git a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/TSUDeskTheme.kt b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/TSUDeskTheme.kt index 4e58784..ea312dd 100644 --- a/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/TSUDeskTheme.kt +++ b/core/ui/src/main/java/ru/fincode/tsudesk/core/ui/theme/TSUDeskTheme.kt @@ -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) { diff --git a/feature/news/build.gradle.kts b/feature/news/build.gradle.kts index d8ab92b..b25dcec 100644 --- a/feature/news/build.gradle.kts +++ b/feature/news/build.gradle.kts @@ -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) diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/NewsRepositoryImpl.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/NewsRepositoryImpl.kt index f7e5db1..4df6d11 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/NewsRepositoryImpl.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/NewsRepositoryImpl.kt @@ -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>> = 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> = + Pager( + config = PagingConfig( + pageSize = 20, + initialLoadSize = 20, + prefetchDistance = 2, + enablePlaceholders = false + ), + pagingSourceFactory = { NewsArchivePagingSource(dataSource) } + ).flow } \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsArchivePagingSource.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsArchivePagingSource.kt new file mode 100644 index 0000000..6e1abf9 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsArchivePagingSource.kt @@ -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() { + + override fun getRefreshKey(state: PagingState): 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): LoadResult { + 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)) + } + } + } +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsPagingException.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsPagingException.kt new file mode 100644 index 0000000..36213d1 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/paging/NewsPagingException.kt @@ -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()) \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/NewsApi.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/api/NewsApi.kt similarity index 65% rename from feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/NewsApi.kt rename to feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/api/NewsApi.kt index 45805a0..c33db66 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/NewsApi.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/api/NewsApi.kt @@ -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 diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/datasource/NewsRemoteDataSource.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/datasource/NewsRemoteDataSource.kt index 5170137..9ec8898 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/datasource/NewsRemoteDataSource.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/datasource/NewsRemoteDataSource.kt @@ -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 = + + suspend fun loadPage(page: Int): NetworkResult = withContext(Dispatchers.IO) { apiCall { api.getNewsArchive(page = page) } - .map { body, _ -> body.string() } + .map { body, _ -> + val html = body.string() + parser.parse(html = html, currentPage = page) + } } } \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsArchiveHtmlParser.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsArchiveHtmlParser.kt new file mode 100644 index 0000000..34141c6 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsArchiveHtmlParser.kt @@ -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, + 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 { + val date = extractDate(text) ?: return emptyList() + val afterDate = text.substringAfter(date).trim() + + return afterDate + .split("/") + .map { it.trim() } + .filter { it.isNotBlank() } + } +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsHtmlParser.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsHtmlParser.kt deleted file mode 100644 index 50dc73a..0000000 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsHtmlParser.kt +++ /dev/null @@ -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 { - val itemRegex = Regex( - pattern = """
([\s\S]*?)
\s*\s*""", - options = setOf(RegexOption.IGNORE_CASE) - ) - - return itemRegex.findAll(html).mapNotNull { match -> - val block = match.value - - val dateString = findFirst( - block, - """\s*(\d{2}\.\d{2}\.\d{4})\s*""" - ) ?: return@mapNotNull null - - val date = parseDate(dateString) ?: return@mapNotNull null - - val category = findFirst( - block, - """\s*\s*([^<]+?)\s*""" - )?.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? { - val linkRegex = Regex( - pattern = """

\s*\s*([\s\S]*?)\s*""", - 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(""", "\"") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("'", "'") -} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/di/NewsModule.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/di/NewsModule.kt index 800885f..d55c504 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/di/NewsModule.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/di/NewsModule.kt @@ -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 diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsItem.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsItem.kt index 5b52a3f..e4ea123 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsItem.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsItem.kt @@ -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 ) \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/repository/NewsRepository.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/repository/NewsRepository.kt index 12553a6..4e8d888 100644 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/repository/NewsRepository.kt +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/repository/NewsRepository.kt @@ -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>> + fun paging(): Flow> } \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/GetNewsPagingUseCase.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/GetNewsPagingUseCase.kt new file mode 100644 index 0000000..acee7dd --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/GetNewsPagingUseCase.kt @@ -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, + scope: CoroutineScope + ): Flow> { + + 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) + } + } + } + } +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/NewsInfinityFeedUseCase.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/NewsInfinityFeedUseCase.kt deleted file mode 100644 index 766fed1..0000000 --- a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/NewsInfinityFeedUseCase.kt +++ /dev/null @@ -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( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - - private val _state = MutableStateFlow(NewsFeedState()) - val state: StateFlow = _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, incoming: List): List { - 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 - } -} \ No newline at end of file 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 index 8e0b5cd..69e6046 100644 --- 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 @@ -9,12 +9,17 @@ import ru.fincode.tsudesk.feature.news.presentation.screen.NewsRoute fun NavGraphBuilder.newsGraph( modifier: Modifier = Modifier, ) { + composable { - NewsRoute(modifier = modifier) + NewsRoute( + modifier = modifier + // onOpenDetails = onOpenDetails + ) } -// composable { entry -> -// val args = entry.toRoute() -// NewsDetailsRoute(id = args.id) -// } -} + // Если позже сделаешь экран деталей — добавишь сюда: + // composable { entry -> + // val args = entry.toRoute() + // NewsDetailsRoute(url = args.url) + // } +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsEvent.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsEvent.kt new file mode 100644 index 0000000..e3332b2 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsEvent.kt @@ -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 +} \ 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 index 29b72ef..d6454ed 100644 --- 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 @@ -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 ) } \ 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 index 99b044f..f5da0e6 100644 --- 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 @@ -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 + ) + } + } + } } \ 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 index 8d8b78e..35e3f56 100644 --- 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 @@ -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 = emptyList(), - val isRefreshing: Boolean = false, - val isAppending: Boolean = false, - val endReached: Boolean = false, - val errorMessage: String? = null + val query: String = "" ) \ 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 4badd3c..af3ff8b 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 @@ -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 = 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 = _state.asStateFlow() + + val newsPaging: Flow> = + 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() - } } \ No newline at end of file diff --git a/feature/schedule/build.gradle.kts b/feature/schedule/build.gradle.kts index ec6ef1d..3210514 100644 --- a/feature/schedule/build.gradle.kts +++ b/feature/schedule/build.gradle.kts @@ -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) diff --git a/feature/splash/build.gradle.kts b/feature/splash/build.gradle.kts index 72b8c56..50fd995 100644 --- a/feature/splash/build.gradle.kts +++ b/feature/splash/build.gradle.kts @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c91883f..efaf583 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }