From 1b962eb07f4d685dafdba3f518cdeed1e894a070 Mon Sep 17 00:00:00 2001 From: Shcherbatykh Oleg Date: Tue, 24 Feb 2026 11:36:11 +0300 Subject: [PATCH] Start impl base news func --- feature/news/build.gradle.kts | 40 ++++++- .../feature/news/data/NewsRepositoryImpl.kt | 28 +++++ .../feature/news/data/remote/NewsApi.kt | 15 +++ .../remote/datasource/NewsRemoteDataSource.kt | 19 ++++ .../news/data/remote/parser/NewsHtmlParser.kt | 89 +++++++++++++++ .../tsudesk/feature/news/di/NewsModule.kt | 30 +++++ .../news/domain/model/NewsFeedState.kt | 11 ++ .../feature/news/domain/model/NewsItem.kt | 11 ++ .../news/domain/repository/NewsRepository.kt | 9 ++ .../domain/usecase/NewsInfinityFeedUseCase.kt | 106 ++++++++++++++++++ .../news/presentation/screen/NewsViewModel.kt | 28 +++++ 11 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/NewsRepositoryImpl.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/NewsApi.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/datasource/NewsRemoteDataSource.kt create 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/di/NewsModule.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsFeedState.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsItem.kt create mode 100644 feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/repository/NewsRepository.kt create 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/NewsViewModel.kt diff --git a/feature/news/build.gradle.kts b/feature/news/build.gradle.kts index cc34dbe..99c35a2 100644 --- a/feature/news/build.gradle.kts +++ b/feature/news/build.gradle.kts @@ -1,6 +1,9 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt) } android { @@ -11,15 +14,16 @@ android { minSdk = libs.versions.minSdk.get().toInt() consumerProguardFiles("consumer-rules.pro") } + buildTypes { release { isMinifyEnabled = false proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } + val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get()) compileOptions { sourceCompatibility = jvm @@ -28,10 +32,38 @@ android { kotlinOptions { jvmTarget = jvm.toString() } + buildFeatures { + buildConfig = true + compose = true + } +} +kapt { + correctErrorTypes = true } - dependencies { implementation(libs.androidx.appcompat) implementation(libs.material) - implementation(libs.core.ktx) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.paging.runtime) + + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + + // Navigation Compose + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation.common) + + kapt(libs.hilt.compiler) + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + + implementation(projects.core.network) + implementation(projects.core.config) + implementation(projects.core.ui) + implementation(projects.core.navigation) + implementation("org.jsoup:jsoup:1.17.2") } \ No newline at end of file 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 new file mode 100644 index 0000000..f7e5db1 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/NewsRepositoryImpl.kt @@ -0,0 +1,28 @@ +package ru.fincode.tsudesk.feature.news.data + +import kotlinx.coroutines.Dispatchers +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.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 +) : 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) +} \ 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/NewsApi.kt new file mode 100644 index 0000000..45805a0 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/NewsApi.kt @@ -0,0 +1,15 @@ +package ru.fincode.tsudesk.feature.news.data.remote + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Query + +interface NewsApi { + + // https://tulsu.ru/news/all?page=534 + @GET("news/all") + suspend fun getNewsArchive( + @Query("page") page: Int, + ): Response +} \ No newline at end of file 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 new file mode 100644 index 0000000..5170137 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/datasource/NewsRemoteDataSource.kt @@ -0,0 +1,19 @@ +package ru.fincode.tsudesk.feature.news.data.remote.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 javax.inject.Inject + +class NewsRemoteDataSource @Inject constructor( + private val api: NewsApi +) { + suspend fun loadArchivePageHtml(page: Int): NetworkResult = + withContext(Dispatchers.IO) { + apiCall { api.getNewsArchive(page = page) } + .map { body, _ -> body.string() } + } +} \ 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 new file mode 100644 index 0000000..55f8d65 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/data/remote/parser/NewsHtmlParser.kt @@ -0,0 +1,89 @@ +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("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 new file mode 100644 index 0000000..800885f --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/di/NewsModule.kt @@ -0,0 +1,30 @@ +package ru.fincode.tsudesk.feature.news.di + +import dagger.Module +import dagger.Provides +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.domain.repository.NewsRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NewsModule { + + @Provides + @Singleton + fun provideNewsApi(retrofit: Retrofit): NewsApi = + retrofit.create(NewsApi::class.java) + + @Provides + @Singleton + fun provideNewsHtmlParser(): NewsHtmlParser = NewsHtmlParser() + + @Provides + @Singleton + fun provideNewsRepository(impl: NewsRepositoryImpl): NewsRepository = impl +} \ No newline at end of file diff --git a/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsFeedState.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsFeedState.kt new file mode 100644 index 0000000..72c4dd4 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsFeedState.kt @@ -0,0 +1,11 @@ +package ru.fincode.tsudesk.feature.news.domain.model + +import ru.fincode.tsudesk.core.network.model.NetworkError + +data class NewsFeedState( + val items: List = emptyList(), + val isLoading: Boolean = false, + val nextPage: Int = 1, + val endReached: Boolean = false, + val lastError: NetworkError? = null +) \ No newline at end of file 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 new file mode 100644 index 0000000..5b52a3f --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/model/NewsItem.kt @@ -0,0 +1,11 @@ +package ru.fincode.tsudesk.feature.news.domain.model + +import java.time.LocalDate + +data class NewsItem( + val id: Long, + val title: String, + val url: String, + val date: LocalDate, + val category: 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 new file mode 100644 index 0000000..12553a6 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/repository/NewsRepository.kt @@ -0,0 +1,9 @@ +package ru.fincode.tsudesk.feature.news.domain.repository + +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>> +} \ 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 new file mode 100644 index 0000000..766fed1 --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/domain/usecase/NewsInfinityFeedUseCase.kt @@ -0,0 +1,106 @@ +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/screen/NewsViewModel.kt b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsViewModel.kt new file mode 100644 index 0000000..ad7c7ee --- /dev/null +++ b/feature/news/src/main/java/ru/fincode/tsudesk/feature/news/presentation/screen/NewsViewModel.kt @@ -0,0 +1,28 @@ +package ru.fincode.tsudesk.feature.news.presentation.screen + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import ru.fincode.tsudesk.feature.news.domain.usecase.NewsInfinityFeedUseCase +import javax.inject.Inject + +@HiltViewModel +class NewsViewModel @Inject constructor( + private val feed: NewsInfinityFeedUseCase +) : ViewModel() { + + val state = feed.state + + init { + feed.attach(viewModelScope) + feed.refresh() + } + + fun onScrolledToEnd() { + feed.loadNext() + } + + fun onRetry() { + feed.loadNext() + } +} \ No newline at end of file