Start impl base news func
This commit is contained in:
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(""", "\"")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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>>>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user