Start impl base news func

This commit is contained in:
Shcherbatykh Oleg
2026-02-24 11:36:11 +03:00
parent c7c2ff62f7
commit 1b962eb07f
11 changed files with 382 additions and 4 deletions

View File

@@ -1,6 +1,9 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt)
} }
android { android {
@@ -11,15 +14,16 @@ android {
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
"proguard-rules.pro"
) )
} }
} }
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get()) val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions { compileOptions {
sourceCompatibility = jvm sourceCompatibility = jvm
@@ -28,10 +32,38 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = jvm.toString() jvmTarget = jvm.toString()
} }
buildFeatures {
buildConfig = true
compose = true
}
}
kapt {
correctErrorTypes = true
} }
dependencies { dependencies {
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) 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")
} }

View File

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

View File

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

View File

@@ -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<String> =
withContext(Dispatchers.IO) {
apiCall { api.getNewsArchive(page = page) }
.map { body, _ -> body.string() }
}
}

View File

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

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

View File

@@ -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<NewsItem> = emptyList(),
val isLoading: Boolean = false,
val nextPage: Int = 1,
val endReached: Boolean = false,
val lastError: NetworkError? = null
)

View File

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

View File

@@ -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<NetworkResult<List<NewsItem>>>
}

View File

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

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