Compare commits
1 Commits
feature/co
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06fa00eb8f |
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
package ru.fincode.tsudesk.feature.news.data
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||
import ru.fincode.tsudesk.feature.news.data.remote.datasource.NewsRemoteDataSource
|
||||
import ru.fincode.tsudesk.feature.news.data.remote.parser.NewsHtmlParser
|
||||
import ru.fincode.tsudesk.feature.news.data.datasource.NewsArchiveDataSource
|
||||
import ru.fincode.tsudesk.feature.news.data.paging.NewsArchivePagingSource
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class NewsRepositoryImpl @Inject constructor(
|
||||
private val remote: NewsRemoteDataSource,
|
||||
private val parser: NewsHtmlParser
|
||||
private val dataSource: NewsArchiveDataSource
|
||||
) : NewsRepository {
|
||||
|
||||
override fun loadPage(page: Int): Flow<NetworkResult<List<NewsItem>>> = flow {
|
||||
when (val result = remote.loadArchivePageHtml(page)) {
|
||||
is NetworkResult.Success -> {
|
||||
val items = parser.parseArchivePage(result.data)
|
||||
emit(NetworkResult.Success(items, result.meta))
|
||||
}
|
||||
is NetworkResult.Error -> emit(result)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
override fun paging(): Flow<PagingData<NewsItem>> =
|
||||
Pager(
|
||||
config = PagingConfig(
|
||||
pageSize = 20,
|
||||
initialLoadSize = 20,
|
||||
prefetchDistance = 2,
|
||||
enablePlaceholders = false
|
||||
),
|
||||
pagingSourceFactory = { NewsArchivePagingSource(dataSource) }
|
||||
).flow
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ru.fincode.tsudesk.feature.news.data.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||
import ru.fincode.tsudesk.feature.news.data.datasource.NewsArchiveDataSource
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
|
||||
class NewsArchivePagingSource(
|
||||
private val dataSource: NewsArchiveDataSource
|
||||
) : PagingSource<Int, NewsItem>() {
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, NewsItem>): Int? {
|
||||
val anchor = state.anchorPosition ?: return null
|
||||
val page = state.closestPageToPosition(anchor)
|
||||
return page?.prevKey?.plus(1) ?: page?.nextKey?.minus(1)
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, NewsItem> {
|
||||
val page = params.key ?: 1
|
||||
|
||||
return when (val res = dataSource.loadPage(page)) {
|
||||
is NetworkResult.Success -> {
|
||||
val parsed = res.data
|
||||
LoadResult.Page(
|
||||
data = parsed.items,
|
||||
prevKey = if (page == 1) null else page - 1,
|
||||
nextKey = if (parsed.hasNextPage) page + 1 else null
|
||||
)
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
LoadResult.Error(NewsPagingException(res.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -1,4 +1,4 @@
|
||||
package ru.fincode.tsudesk.feature.news.data.remote
|
||||
package ru.fincode.tsudesk.feature.news.data.remote.api
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
@@ -7,8 +7,7 @@ import retrofit2.http.Query
|
||||
|
||||
interface NewsApi {
|
||||
|
||||
// https://tulsu.ru/news/all?page=534
|
||||
@GET("news/all")
|
||||
@GET("press-releases/university")
|
||||
suspend fun getNewsArchive(
|
||||
@Query("page") page: Int,
|
||||
): Response<ResponseBody>
|
||||
@@ -1,19 +1,25 @@
|
||||
package ru.fincode.tsudesk.feature.news.data.remote.datasource
|
||||
package ru.fincode.tsudesk.feature.news.data.datasource
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fincode.tsudesk.core.network.apiCall
|
||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||
import ru.fincode.tsudesk.core.network.model.map
|
||||
import ru.fincode.tsudesk.feature.news.data.remote.NewsApi
|
||||
import ru.fincode.tsudesk.feature.news.data.parser.NewsArchiveHtmlParser
|
||||
import ru.fincode.tsudesk.feature.news.data.remote.api.NewsApi
|
||||
import javax.inject.Inject
|
||||
|
||||
class NewsRemoteDataSource @Inject constructor(
|
||||
private val api: NewsApi
|
||||
class NewsArchiveDataSource @Inject constructor(
|
||||
private val api: NewsApi,
|
||||
private val parser: NewsArchiveHtmlParser
|
||||
) {
|
||||
suspend fun loadArchivePageHtml(page: Int): NetworkResult<String> =
|
||||
|
||||
suspend fun loadPage(page: Int): NetworkResult<NewsArchiveHtmlParser.ParsedPage> =
|
||||
withContext(Dispatchers.IO) {
|
||||
apiCall { api.getNewsArchive(page = page) }
|
||||
.map { body, _ -> body.string() }
|
||||
.map { body, _ ->
|
||||
val html = body.string()
|
||||
parser.parse(html = html, currentPage = page)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.fincode.tsudesk.feature.news.data.parser
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Чистый парсер HTML архива:
|
||||
* - без baseUrl/baseUri
|
||||
* - без https://...
|
||||
* - парсит grid карточек из view-source
|
||||
*/
|
||||
class NewsArchiveHtmlParser @Inject constructor() {
|
||||
|
||||
data class ParsedPage(
|
||||
val items: List<NewsItem>,
|
||||
val hasNextPage: Boolean
|
||||
)
|
||||
|
||||
fun parse(html: String, currentPage: Int): ParsedPage {
|
||||
val doc = Jsoup.parse(html)
|
||||
|
||||
val cards = doc.select("main#press-releases-content a.card.card-ghost[href*=/press-releases/]")
|
||||
val items = cards.mapNotNull { card ->
|
||||
val href = card.attr("href").trim()
|
||||
if (href.isBlank() || !href.contains("/press-releases/")) return@mapNotNull null
|
||||
|
||||
val id = href.substringAfterLast('/').takeWhile { it.isDigit() }
|
||||
if (id.isBlank()) return@mapNotNull null
|
||||
|
||||
val title = card.selectFirst("h6.card-title")?.text()?.trim().orEmpty()
|
||||
if (title.isBlank()) return@mapNotNull null
|
||||
|
||||
val metaText = card.selectFirst("p.card-text")?.text()?.trim().orEmpty()
|
||||
val dateText = extractDate(metaText) ?: return@mapNotNull null
|
||||
|
||||
val tags = extractTags(metaText)
|
||||
val category = tags.firstOrNull() ?: "Новости"
|
||||
|
||||
val imageUrl = card.selectFirst("img.card-img[src]")
|
||||
?.attr("src")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
NewsItem(
|
||||
id = id,
|
||||
title = title,
|
||||
dateText = dateText,
|
||||
imageUrl = imageUrl,
|
||||
category = category,
|
||||
url = href
|
||||
)
|
||||
}.distinctBy { it.id }
|
||||
|
||||
val hasNextPage = doc.select("a[href*=\"page=${currentPage + 1}\"]").isNotEmpty()
|
||||
|
||||
return ParsedPage(items = items, hasNextPage = hasNextPage)
|
||||
}
|
||||
|
||||
private fun extractDate(text: String): String? {
|
||||
val regex = Regex("""\b\d{2}\.\d{2}\.\d{4}(?:\s+\d{2}:\d{2})?\b""")
|
||||
return regex.find(text)?.value
|
||||
}
|
||||
|
||||
private fun extractTags(text: String): List<String> {
|
||||
val date = extractDate(text) ?: return emptyList()
|
||||
val afterDate = text.substringAfter(date).trim()
|
||||
|
||||
return afterDate
|
||||
.split("/")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package ru.fincode.tsudesk.feature.news.data.remote.parser
|
||||
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.Locale
|
||||
|
||||
class NewsHtmlParser {
|
||||
|
||||
private val dateFormatter =
|
||||
DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.forLanguageTag("ru"))
|
||||
|
||||
fun parseArchivePage(html: String): List<NewsItem> {
|
||||
val itemRegex = Regex(
|
||||
pattern = """<div class="single-blog">([\s\S]*?)</div>\s*</div>\s*</div>""",
|
||||
options = setOf(RegexOption.IGNORE_CASE)
|
||||
)
|
||||
|
||||
return itemRegex.findAll(html).mapNotNull { match ->
|
||||
val block = match.value
|
||||
|
||||
val dateString = findFirst(
|
||||
block,
|
||||
"""<span>\s*(\d{2}\.\d{2}\.\d{4})\s*</span>"""
|
||||
) ?: return@mapNotNull null
|
||||
|
||||
val date = parseDate(dateString) ?: return@mapNotNull null
|
||||
|
||||
val category = findFirst(
|
||||
block,
|
||||
"""</ul>\s*<span>\s*([^<]+?)\s*</span>"""
|
||||
)?.trim().orEmpty()
|
||||
|
||||
val (url, title) = findLinkAndTitle(block) ?: return@mapNotNull null
|
||||
val id = extractIdFromUrl(url) ?: return@mapNotNull null
|
||||
|
||||
NewsItem(
|
||||
id = id,
|
||||
title = htmlUnescape(title.trim()),
|
||||
url = url.trim(),
|
||||
date = date,
|
||||
category = htmlUnescape(category.trim())
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun parseDate(raw: String): LocalDate? =
|
||||
try {
|
||||
LocalDate.parse(raw.trim(), dateFormatter)
|
||||
} catch (_: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun findLinkAndTitle(block: String): Pair<String, String>? {
|
||||
val linkRegex = Regex(
|
||||
pattern = """<h3>\s*<a\s+href="([^"]+)">\s*([\s\S]*?)\s*</a>""",
|
||||
options = setOf(RegexOption.IGNORE_CASE)
|
||||
)
|
||||
val m = linkRegex.find(block) ?: return null
|
||||
val url = m.groupValues[1]
|
||||
val title = stripTags(m.groupValues[2])
|
||||
return url to title
|
||||
}
|
||||
|
||||
private fun extractIdFromUrl(url: String): Long? {
|
||||
val idRegex = Regex(""".*/news/all/(\d+).*""")
|
||||
val m = idRegex.matchEntire(url) ?: return null
|
||||
return m.groupValues[1].toLongOrNull()
|
||||
}
|
||||
|
||||
private fun findFirst(text: String, pattern: String): String? =
|
||||
Regex(pattern, RegexOption.IGNORE_CASE)
|
||||
.find(text)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
|
||||
private fun stripTags(s: String): String =
|
||||
s.replace(Regex("<[^>]*>"), " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
||||
private fun htmlUnescape(s: String): String =
|
||||
s.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("'", "'")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
package ru.fincode.tsudesk.feature.news.domain.repository
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
|
||||
interface NewsRepository {
|
||||
fun loadPage(page: Int): Flow<NetworkResult<List<NewsItem>>>
|
||||
fun paging(): Flow<PagingData<NewsItem>>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ru.fincode.tsudesk.feature.news.domain.usecase
|
||||
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.filter
|
||||
import androidx.paging.cachedIn
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetNewsPagingUseCase @Inject constructor(
|
||||
private val repository: NewsRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* @param queryFlow поток поискового запроса
|
||||
* @param scope scope для cachedIn
|
||||
*/
|
||||
operator fun invoke(
|
||||
queryFlow: Flow<String>,
|
||||
scope: CoroutineScope
|
||||
): Flow<PagingData<NewsItem>> {
|
||||
|
||||
val pagingBase = repository
|
||||
.paging()
|
||||
.cachedIn(scope)
|
||||
|
||||
val debouncedQuery = queryFlow
|
||||
.map(String::trim)
|
||||
.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
|
||||
return combine(pagingBase, debouncedQuery) { paging, query ->
|
||||
if (query.isBlank()) {
|
||||
paging
|
||||
} else {
|
||||
paging.filter {
|
||||
it.title.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package ru.fincode.tsudesk.feature.news.domain.usecase
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsFeedState
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class NewsInfinityFeedUseCase @Inject constructor(
|
||||
private val repo: NewsRepository
|
||||
) {
|
||||
private val actions = MutableSharedFlow<Action>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(NewsFeedState())
|
||||
val state: StateFlow<NewsFeedState> = _state.asStateFlow()
|
||||
|
||||
fun attach(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
actions.collect { action ->
|
||||
when (action) {
|
||||
Action.Refresh -> doRefresh()
|
||||
Action.LoadNext -> doLoadNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
actions.tryEmit(Action.Refresh)
|
||||
}
|
||||
|
||||
fun loadNext() {
|
||||
actions.tryEmit(Action.LoadNext)
|
||||
}
|
||||
|
||||
private suspend fun doRefresh() {
|
||||
_state.value =
|
||||
NewsFeedState(isLoading = true, nextPage = 1, endReached = false, lastError = null)
|
||||
loadPageIntoState(page = 1, replace = true)
|
||||
}
|
||||
|
||||
private suspend fun doLoadNext() {
|
||||
val s = _state.value
|
||||
if (s.isLoading || s.endReached) return
|
||||
_state.update { it.copy(isLoading = true, lastError = null) }
|
||||
loadPageIntoState(page = s.nextPage, replace = false)
|
||||
}
|
||||
|
||||
private suspend fun loadPageIntoState(page: Int, replace: Boolean) {
|
||||
repo.loadPage(page).collect { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val incoming = result.data
|
||||
_state.update { current ->
|
||||
val merged = if (replace) {
|
||||
incoming
|
||||
} else {
|
||||
mergeUnique(current.items, incoming)
|
||||
}
|
||||
|
||||
current.copy(
|
||||
items = merged,
|
||||
isLoading = false,
|
||||
nextPage = page + 1,
|
||||
endReached = incoming.isEmpty(),
|
||||
lastError = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Error -> {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
isLoading = false,
|
||||
lastError = result.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeUnique(existing: List<NewsItem>, incoming: List<NewsItem>): List<NewsItem> {
|
||||
if (incoming.isEmpty()) return existing
|
||||
val seen = existing.asSequence().map { it.id }.toHashSet()
|
||||
val add = incoming.filter { seen.add(it.id) }
|
||||
return existing + add
|
||||
}
|
||||
|
||||
private sealed interface Action {
|
||||
data object Refresh : Action
|
||||
data object LoadNext : Action
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,17 @@ import ru.fincode.tsudesk.feature.news.presentation.screen.NewsRoute
|
||||
fun NavGraphBuilder.newsGraph(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
composable<AppRoute.News> {
|
||||
NewsRoute(modifier = modifier)
|
||||
NewsRoute(
|
||||
modifier = modifier
|
||||
// onOpenDetails = onOpenDetails
|
||||
)
|
||||
}
|
||||
|
||||
// composable<AppRoute.NewsDetails> { entry ->
|
||||
// val args = entry.toRoute<AppRoute.NewsDetails>()
|
||||
// NewsDetailsRoute(id = args.id)
|
||||
// }
|
||||
}
|
||||
// Если позже сделаешь экран деталей — добавишь сюда:
|
||||
// composable<AppRoute.NewsDetails> { entry ->
|
||||
// val args = entry.toRoute<AppRoute.NewsDetails>()
|
||||
// NewsDetailsRoute(url = args.url)
|
||||
// }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
|
||||
data class NewsUiState(
|
||||
val items: List<NewsItem> = emptyList(),
|
||||
val isRefreshing: Boolean = false,
|
||||
val isAppending: Boolean = false,
|
||||
val endReached: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
val query: String = ""
|
||||
)
|
||||
@@ -2,55 +2,39 @@ package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import ru.fincode.tsudesk.feature.news.domain.usecase.NewsInfinityFeedUseCase
|
||||
import kotlinx.coroutines.flow.update
|
||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||
import ru.fincode.tsudesk.feature.news.domain.usecase.GetNewsPagingUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NewsViewModel @Inject constructor(
|
||||
private val feed: NewsInfinityFeedUseCase
|
||||
private val getNewsPagingUseCase: GetNewsPagingUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<NewsUiState> = feed.state
|
||||
.map { s ->
|
||||
NewsUiState(
|
||||
items = s.items,
|
||||
// минимально: считаем, что "refreshing" — это page=1 и loading
|
||||
isRefreshing = s.isLoading && s.nextPage == 1,
|
||||
// "appending" — loading после первой страницы
|
||||
isAppending = s.isLoading && s.nextPage > 1,
|
||||
endReached = s.endReached,
|
||||
errorMessage = s.lastError?.toString()
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = NewsUiState(isRefreshing = true)
|
||||
private val _state = MutableStateFlow(NewsUiState())
|
||||
val state: StateFlow<NewsUiState> = _state.asStateFlow()
|
||||
|
||||
val newsPaging: Flow<PagingData<NewsItem>> =
|
||||
getNewsPagingUseCase(
|
||||
queryFlow = state.map { it.query },
|
||||
scope = viewModelScope
|
||||
)
|
||||
|
||||
init {
|
||||
feed.attach(viewModelScope)
|
||||
feed.refresh()
|
||||
}
|
||||
fun onEvent(event: NewsEvent) {
|
||||
when (event) {
|
||||
is NewsEvent.QueryChanged ->
|
||||
_state.update { it.copy(query = event.value) }
|
||||
|
||||
fun onIntent(intent: NewsIntent) {
|
||||
when (intent) {
|
||||
NewsIntent.Refresh -> feed.refresh()
|
||||
NewsIntent.LoadNext -> feed.loadNext()
|
||||
NewsIntent.Retry -> feed.loadNext()
|
||||
is NewsIntent.Open -> {
|
||||
// навигацию лучше делать через callback из Route (см ниже),
|
||||
// здесь пока no-op
|
||||
}
|
||||
NewsEvent.ClearQuery ->
|
||||
_state.update { it.copy(query = "") }
|
||||
}
|
||||
}
|
||||
|
||||
fun onScrolledToEnd() {
|
||||
feed.loadNext()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user