Compare commits
3 Commits
feature/da
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06fa00eb8f | ||
| 075dac1fc7 | |||
| bc07ea421b |
@@ -53,11 +53,13 @@ kapt {
|
|||||||
dependencies {
|
dependencies {
|
||||||
// Compose
|
// Compose
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
|
||||||
|
|
||||||
// Navigation Compose
|
// Navigation Compose
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ package ru.fincode.tsudesk
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import ru.fincode.tsudesk.core.ui.components.ConfigureSystemBars
|
||||||
|
import ru.fincode.tsudesk.core.ui.theme.TSUDeskTheme
|
||||||
|
import ru.fincode.tsudesk.core.ui.theme.TSUDeskThemeExt
|
||||||
import ru.fincode.tsudesk.presentation.TSUDeskApp
|
import ru.fincode.tsudesk.presentation.TSUDeskApp
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -11,9 +16,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
setContent {
|
setContent {
|
||||||
|
TSUDeskTheme {
|
||||||
|
ConfigureSystemBars(
|
||||||
|
statusBarColor = Color.Transparent,
|
||||||
|
navigationBarColor = TSUDeskThemeExt.colors.brand,
|
||||||
|
darkIcons = false
|
||||||
|
)
|
||||||
TSUDeskApp()
|
TSUDeskApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,34 @@
|
|||||||
package ru.fincode.tsudesk.presentation.main
|
package ru.fincode.tsudesk.presentation.main
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
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.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import ru.fincode.core.ui.components.BottomBarItem
|
|
||||||
import ru.fincode.core.ui.components.TsudeskBottomBar
|
|
||||||
import ru.fincode.tsudesk.R
|
import ru.fincode.tsudesk.R
|
||||||
import ru.fincode.tsudesk.core.navigation.TopLevelDestination
|
import ru.fincode.tsudesk.core.navigation.TopLevelDestination
|
||||||
import ru.fincode.tsudesk.core.navigation.navigateToTopLevel
|
import ru.fincode.tsudesk.core.navigation.navigateToTopLevel
|
||||||
|
import ru.fincode.tsudesk.core.ui.components.BottomBarItem
|
||||||
|
import ru.fincode.tsudesk.core.ui.components.TsudeskBottomBar
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScaffold(
|
fun MainScaffold(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
content: @Composable (Modifier) -> Unit,
|
content: @Composable (Modifier) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scheduleIcon =
|
val scheduleIcon = Icons.Rounded.CalendarToday
|
||||||
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_progress)
|
val newsIcon = Icons.Rounded.Newspaper
|
||||||
val newsIcon =
|
val progressIcon = Icons.Rounded.School
|
||||||
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_progress)
|
val settingsIcon = Icons.Rounded.Settings
|
||||||
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 items = listOf(
|
val items = listOf(
|
||||||
TopLevelItem(TopLevelDestination.SCHEDULE, R.string.tab_schedule, scheduleIcon),
|
TopLevelItem(TopLevelDestination.SCHEDULE, R.string.tab_schedule, scheduleIcon),
|
||||||
@@ -40,6 +41,7 @@ fun MainScaffold(
|
|||||||
val selected = selectedTopLevel(backStackEntry)
|
val selected = selectedTopLevel(backStackEntry)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets(0),
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (shouldShowBottomBar(backStackEntry)) {
|
if (shouldShowBottomBar(backStackEntry)) {
|
||||||
val uiItems = items.map { item ->
|
val uiItems = items.map { item ->
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
// Compose
|
// Compose
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
// Navigation Compose
|
// Navigation Compose
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ dependencies {
|
|||||||
|
|
||||||
|
|
||||||
// Compose UI
|
// Compose UI
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package ru.fincode.core.ui.components
|
package ru.fincode.tsudesk.core.ui.components
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfigureSystemBars(
|
||||||
|
statusBarColor: Color,
|
||||||
|
navigationBarColor: Color = statusBarColor,
|
||||||
|
darkIcons: Boolean = false, // false = светлые иконки
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val status = statusBarColor.toArgb()
|
||||||
|
val nav = navigationBarColor.toArgb()
|
||||||
|
|
||||||
|
SideEffect {
|
||||||
|
val activity = context as? ComponentActivity ?: return@SideEffect
|
||||||
|
|
||||||
|
activity.enableEdgeToEdge(
|
||||||
|
statusBarStyle = if (darkIcons) {
|
||||||
|
SystemBarStyle.light(status, status)
|
||||||
|
} else {
|
||||||
|
SystemBarStyle.dark(status)
|
||||||
|
},
|
||||||
|
navigationBarStyle = SystemBarStyle.auto(
|
||||||
|
lightScrim = nav,
|
||||||
|
darkScrim = nav,
|
||||||
|
detectDarkMode = { darkIcons }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package ru.fincode.core.ui.components
|
package ru.fincode.tsudesk.core.ui.components
|
||||||
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Brand (ТулГУ / TSUDesk)
|
||||||
|
val BrandRed = Color(0xFF8B1A1A)
|
||||||
|
val OnBrand = Color(0xFFFFFFFF)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
|
||||||
|
val LightColorScheme = lightColorScheme(
|
||||||
|
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 = 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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
data class ExtendedColors(
|
||||||
|
val brand: Color,
|
||||||
|
val onBrand: Color,
|
||||||
|
val brandSoft: Color,
|
||||||
|
val brandGradientStart: Color,
|
||||||
|
val brandGradientEnd: Color,
|
||||||
|
val borderSoft: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalExtendedColors = staticCompositionLocalOf {
|
||||||
|
ExtendedColors(
|
||||||
|
brand = Color.Unspecified,
|
||||||
|
onBrand = Color.Unspecified,
|
||||||
|
brandSoft = Color.Unspecified,
|
||||||
|
brandGradientStart = Color.Unspecified,
|
||||||
|
brandGradientEnd = Color.Unspecified,
|
||||||
|
borderSoft = Color.Unspecified,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TSUDeskTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val scheme = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||||
|
|
||||||
|
val extended = ExtendedColors(
|
||||||
|
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) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = scheme,
|
||||||
|
typography = TSUDeskTypography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
|
||||||
|
object TSUDeskThemeExt {
|
||||||
|
val colors: ExtendedColors
|
||||||
|
@Composable get() = LocalExtendedColors.current
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.fincode.tsudesk.core.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
|
||||||
|
val TSUDeskTypography = Typography()
|
||||||
@@ -41,25 +41,33 @@ kapt {
|
|||||||
correctErrorTypes = true
|
correctErrorTypes = true
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Android
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.google.material)
|
||||||
implementation(libs.kotlinx.collections.immutable)
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
implementation(libs.androidx.paging.runtime)
|
implementation(libs.androidx.paging.runtime)
|
||||||
|
|
||||||
|
// Paging
|
||||||
|
implementation(libs.androidx.paging.runtime)
|
||||||
|
implementation(libs.androidx.paging.compose)
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
|
|
||||||
|
// Coil
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
// Navigation Compose
|
// Navigation Compose
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
implementation(libs.androidx.navigation.common)
|
|
||||||
|
|
||||||
|
// Hilt
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|
||||||
implementation(projects.core.network)
|
implementation(projects.core.network)
|
||||||
implementation(projects.core.config)
|
implementation(projects.core.config)
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
package ru.fincode.tsudesk.feature.news.data
|
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.flow
|
import ru.fincode.tsudesk.feature.news.data.datasource.NewsArchiveDataSource
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import ru.fincode.tsudesk.feature.news.data.paging.NewsArchivePagingSource
|
||||||
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.model.NewsItem
|
||||||
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class NewsRepositoryImpl @Inject constructor(
|
class NewsRepositoryImpl @Inject constructor(
|
||||||
private val remote: NewsRemoteDataSource,
|
private val dataSource: NewsArchiveDataSource
|
||||||
private val parser: NewsHtmlParser
|
|
||||||
) : NewsRepository {
|
) : NewsRepository {
|
||||||
|
|
||||||
override fun loadPage(page: Int): Flow<NetworkResult<List<NewsItem>>> = flow {
|
override fun paging(): Flow<PagingData<NewsItem>> =
|
||||||
when (val result = remote.loadArchivePageHtml(page)) {
|
Pager(
|
||||||
is NetworkResult.Success -> {
|
config = PagingConfig(
|
||||||
val items = parser.parseArchivePage(result.data)
|
pageSize = 20,
|
||||||
emit(NetworkResult.Success(items, result.meta))
|
initialLoadSize = 20,
|
||||||
}
|
prefetchDistance = 2,
|
||||||
is NetworkResult.Error -> emit(result)
|
enablePlaceholders = false
|
||||||
}
|
),
|
||||||
}.flowOn(Dispatchers.IO)
|
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 okhttp3.ResponseBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
@@ -7,8 +7,7 @@ import retrofit2.http.Query
|
|||||||
|
|
||||||
interface NewsApi {
|
interface NewsApi {
|
||||||
|
|
||||||
// https://tulsu.ru/news/all?page=534
|
@GET("press-releases/university")
|
||||||
@GET("news/all")
|
|
||||||
suspend fun getNewsArchive(
|
suspend fun getNewsArchive(
|
||||||
@Query("page") page: Int,
|
@Query("page") page: Int,
|
||||||
): Response<ResponseBody>
|
): 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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.fincode.tsudesk.core.network.apiCall
|
import ru.fincode.tsudesk.core.network.apiCall
|
||||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
||||||
import ru.fincode.tsudesk.core.network.model.map
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
class NewsRemoteDataSource @Inject constructor(
|
class NewsArchiveDataSource @Inject constructor(
|
||||||
private val api: NewsApi
|
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) {
|
withContext(Dispatchers.IO) {
|
||||||
apiCall { api.getNewsArchive(page = page) }
|
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 dagger.hilt.components.SingletonComponent
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import ru.fincode.tsudesk.feature.news.data.NewsRepositoryImpl
|
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.api.NewsApi
|
||||||
import ru.fincode.tsudesk.feature.news.data.remote.parser.NewsHtmlParser
|
|
||||||
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
import ru.fincode.tsudesk.feature.news.domain.repository.NewsRepository
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -20,10 +19,6 @@ object NewsModule {
|
|||||||
fun provideNewsApi(retrofit: Retrofit): NewsApi =
|
fun provideNewsApi(retrofit: Retrofit): NewsApi =
|
||||||
retrofit.create(NewsApi::class.java)
|
retrofit.create(NewsApi::class.java)
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideNewsHtmlParser(): NewsHtmlParser = NewsHtmlParser()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideNewsRepository(impl: NewsRepositoryImpl): NewsRepository = impl
|
fun provideNewsRepository(impl: NewsRepositoryImpl): NewsRepository = impl
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package ru.fincode.tsudesk.feature.news.domain.model
|
package ru.fincode.tsudesk.feature.news.domain.model
|
||||||
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
data class NewsItem(
|
data class NewsItem(
|
||||||
val id: Long,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val url: String,
|
val dateText: String,
|
||||||
val date: LocalDate,
|
val imageUrl: String?,
|
||||||
val category: String
|
val category: String,
|
||||||
|
val excerpt: String? = null,
|
||||||
|
val url: String
|
||||||
)
|
)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package ru.fincode.tsudesk.feature.news.domain.repository
|
package ru.fincode.tsudesk.feature.news.domain.repository
|
||||||
|
|
||||||
|
import androidx.paging.PagingData
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import ru.fincode.tsudesk.core.network.model.NetworkResult
|
|
||||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||||
|
|
||||||
interface NewsRepository {
|
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(
|
fun NavGraphBuilder.newsGraph(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
composable<AppRoute.News> {
|
composable<AppRoute.News> {
|
||||||
NewsRoute(modifier = modifier)
|
NewsRoute(
|
||||||
|
modifier = modifier
|
||||||
|
// onOpenDetails = onOpenDetails
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// composable<AppRoute.NewsDetails> { entry ->
|
// Если позже сделаешь экран деталей — добавишь сюда:
|
||||||
// val args = entry.toRoute<AppRoute.NewsDetails>()
|
// composable<AppRoute.NewsDetails> { entry ->
|
||||||
// NewsDetailsRoute(id = args.id)
|
// 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
|
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewsRoute(
|
fun NewsRoute(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier
|
||||||
onOpen: ((NewsItem) -> Unit)? = null,
|
|
||||||
viewModel: NewsViewModel = hiltViewModel(),
|
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val viewModel: NewsViewModel = hiltViewModel()
|
||||||
|
|
||||||
NewsScreen(
|
NewsScreen(
|
||||||
state = state,
|
modifier = modifier,
|
||||||
onIntent = { intent ->
|
viewModel = viewModel
|
||||||
when (intent) {
|
|
||||||
is NewsIntent.Open -> onOpen?.invoke(intent.item)
|
|
||||||
else -> viewModel.onIntent(intent)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onScrolledToEnd = viewModel::onScrolledToEnd,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,150 +1,201 @@
|
|||||||
package ru.fincode.tsudesk.feature.news.presentation.screen
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.format.DateTimeFormatter
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewsScreen(
|
fun NewsScreen(
|
||||||
state: NewsUiState,
|
|
||||||
onIntent: (NewsIntent) -> Unit,
|
|
||||||
onScrolledToEnd: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: NewsViewModel
|
||||||
) {
|
) {
|
||||||
// Пусто + refreshing: показываем “загрузка”
|
val state by viewModel.state.collectAsState()
|
||||||
if (state.items.isEmpty() && state.isRefreshing) {
|
val pagingItems = viewModel.newsPaging.collectAsLazyPagingItems()
|
||||||
Column(
|
val colors = MaterialTheme.colorScheme
|
||||||
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")
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer: догрузка
|
|
||||||
item {
|
item {
|
||||||
if (state.isAppending) {
|
NewsHeader(
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
query = state.query,
|
||||||
CircularProgressIndicator()
|
onQueryChange = {
|
||||||
|
viewModel.onEvent(NewsEvent.QueryChanged(it))
|
||||||
}
|
}
|
||||||
} 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("Повторить")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NewsRow(
|
private fun NewsHeader(
|
||||||
title: String,
|
query: String,
|
||||||
meta: String,
|
onQueryChange: (String) -> Unit
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
|
val colors = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onClick)
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.background(
|
||||||
) {
|
colors.primaryContainer,
|
||||||
Text(
|
shape = RoundedCornerShape(
|
||||||
text = title,
|
bottomStart = 40.dp,
|
||||||
style = MaterialTheme.typography.titleMedium
|
bottomEnd = 40.dp
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = meta,
|
"Новости",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
color = colors.onPrimaryContainer
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"ТулГУ · Последние обновления",
|
||||||
|
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
|
package ru.fincode.tsudesk.feature.news.presentation.screen
|
||||||
|
|
||||||
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
|
||||||
|
|
||||||
data class NewsUiState(
|
data class NewsUiState(
|
||||||
val items: List<NewsItem> = emptyList(),
|
val query: String = ""
|
||||||
val isRefreshing: Boolean = false,
|
|
||||||
val isAppending: Boolean = false,
|
|
||||||
val endReached: Boolean = false,
|
|
||||||
val errorMessage: String? = null
|
|
||||||
)
|
)
|
||||||
@@ -2,55 +2,39 @@ package ru.fincode.tsudesk.feature.news.presentation.screen
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.PagingData
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.update
|
||||||
import ru.fincode.tsudesk.feature.news.domain.usecase.NewsInfinityFeedUseCase
|
import ru.fincode.tsudesk.feature.news.domain.model.NewsItem
|
||||||
|
import ru.fincode.tsudesk.feature.news.domain.usecase.GetNewsPagingUseCase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NewsViewModel @Inject constructor(
|
class NewsViewModel @Inject constructor(
|
||||||
private val feed: NewsInfinityFeedUseCase
|
private val getNewsPagingUseCase: GetNewsPagingUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<NewsUiState> = feed.state
|
private val _state = MutableStateFlow(NewsUiState())
|
||||||
.map { s ->
|
val state: StateFlow<NewsUiState> = _state.asStateFlow()
|
||||||
NewsUiState(
|
|
||||||
items = s.items,
|
val newsPaging: Flow<PagingData<NewsItem>> =
|
||||||
// минимально: считаем, что "refreshing" — это page=1 и loading
|
getNewsPagingUseCase(
|
||||||
isRefreshing = s.isLoading && s.nextPage == 1,
|
queryFlow = state.map { it.query },
|
||||||
// "appending" — loading после первой страницы
|
scope = viewModelScope
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
fun onEvent(event: NewsEvent) {
|
||||||
feed.attach(viewModelScope)
|
when (event) {
|
||||||
feed.refresh()
|
is NewsEvent.QueryChanged ->
|
||||||
}
|
_state.update { it.copy(query = event.value) }
|
||||||
|
|
||||||
fun onIntent(intent: NewsIntent) {
|
NewsEvent.ClearQuery ->
|
||||||
when (intent) {
|
_state.update { it.copy(query = "") }
|
||||||
NewsIntent.Refresh -> feed.refresh()
|
|
||||||
NewsIntent.LoadNext -> feed.loadNext()
|
|
||||||
NewsIntent.Retry -> feed.loadNext()
|
|
||||||
is NewsIntent.Open -> {
|
|
||||||
// навигацию лучше делать через callback из Route (см ниже),
|
|
||||||
// здесь пока no-op
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun onScrolledToEnd() {
|
|
||||||
feed.loadNext()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.collections.immutable)
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
|
|
||||||
// Compose UI
|
// Compose UI
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
@@ -60,7 +60,7 @@ dependencies {
|
|||||||
// DI
|
// DI
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation(libs.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|
||||||
// Core modules
|
// Core modules
|
||||||
implementation(projects.core.common)
|
implementation(projects.core.common)
|
||||||
|
|||||||
@@ -3,7 +3,21 @@ package ru.fincode.tsudesk.feature.schedule.presentation.screen
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.WindowInsets
|
||||||
|
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.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -14,9 +28,23 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.AssistChipDefaults
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
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.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -28,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import ru.fincode.tsudesk.core.common.model.DataResult
|
import ru.fincode.tsudesk.core.common.model.DataResult
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.*
|
import ru.fincode.tsudesk.core.ui.theme.TSUDeskThemeExt
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
|
||||||
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
|
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
|
||||||
|
|
||||||
@@ -60,31 +88,38 @@ private fun ScheduleHeader(
|
|||||||
onIntent: (ScheduleIntent) -> Unit
|
onIntent: (ScheduleIntent) -> Unit
|
||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
|
val shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
val brand = TSUDeskThemeExt.colors
|
||||||
|
|
||||||
val headerBrush = Brush.linearGradient(
|
val headerBrush = Brush.linearGradient(
|
||||||
listOf(
|
listOf(
|
||||||
MaterialTheme.colorScheme.error,
|
brand.brand,
|
||||||
MaterialTheme.colorScheme.error.copy(alpha = 0.75f)
|
brand.brand.copy(alpha = 0.75f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(headerBrush)
|
.background(headerBrush)
|
||||||
.padding(top = 20.dp, start = 16.dp, end = 16.dp, bottom = 14.dp)
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
// inset только для содержимого, фон остаётся до верха
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 14.dp)
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Расписание",
|
text = "Расписание",
|
||||||
color = MaterialTheme.colorScheme.onError,
|
color = brand.onBrand,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "ТулГУ • Осенний семестр",
|
text = "ТулГУ • Осенний семестр",
|
||||||
color = MaterialTheme.colorScheme.onError.copy(alpha = 0.8f),
|
color = brand.onBrand.copy(alpha = 0.8f),
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
@@ -105,18 +140,16 @@ private fun ScheduleHeader(
|
|||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
|
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
|
||||||
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
|
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
|
||||||
|
|
||||||
// выбор из списка -> сразу загрузка
|
|
||||||
onSelectRecent = { group ->
|
onSelectRecent = { group ->
|
||||||
onIntent(ScheduleIntent.SelectRecentGroup(group))
|
onIntent(ScheduleIntent.SelectRecentGroup(group))
|
||||||
onIntent(ScheduleIntent.ApplyGroup)
|
onIntent(ScheduleIntent.ApplyGroup)
|
||||||
},
|
},
|
||||||
|
|
||||||
onRemoveRecent = { onIntent(ScheduleIntent.RemoveRecentGroup(it)) },
|
onRemoveRecent = { onIntent(ScheduleIntent.RemoveRecentGroup(it)) },
|
||||||
onApply = { onIntent(ScheduleIntent.ApplyGroup) },
|
onApply = { onIntent(ScheduleIntent.ApplyGroup) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -566,9 +599,10 @@ private fun LessonCard(lesson: UiLesson) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LessonBadge(text: String, kind: BadgeKind) {
|
private fun LessonBadge(text: String, kind: BadgeKind) {
|
||||||
|
val brand = TSUDeskThemeExt.colors
|
||||||
val (bg, fg) = when (kind) {
|
val (bg, fg) = when (kind) {
|
||||||
BadgeKind.INFO -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.primary
|
BadgeKind.INFO -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.primary
|
||||||
BadgeKind.PRIMARY -> MaterialTheme.colorScheme.error.copy(alpha = 0.12f) to MaterialTheme.colorScheme.error
|
BadgeKind.PRIMARY -> brand.brandSoft to brand.brand
|
||||||
BadgeKind.LAB -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.tertiary
|
BadgeKind.LAB -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.tertiary
|
||||||
BadgeKind.MUTED -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
BadgeKind.MUTED -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ kapt {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.material)
|
implementation(libs.google.material)
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
@@ -56,7 +56,7 @@ dependencies {
|
|||||||
|
|
||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|
||||||
implementation(projects.core.network)
|
implementation(projects.core.network)
|
||||||
implementation(projects.core.common)
|
implementation(projects.core.common)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
|
# SDK / build
|
||||||
compileSdk = "36"
|
compileSdk = "36"
|
||||||
minSdk = "26"
|
minSdk = "26"
|
||||||
targetSdk = "36"
|
targetSdk = "36"
|
||||||
@@ -8,37 +9,38 @@ versionCode = "1"
|
|||||||
agp = "8.12.0"
|
agp = "8.12.0"
|
||||||
kotlin = "1.9.24"
|
kotlin = "1.9.24"
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
|
kotlinCompilerExtension = "1.5.14"
|
||||||
|
|
||||||
|
# KotlinX
|
||||||
coroutines = "1.8.1"
|
coroutines = "1.8.1"
|
||||||
serialization = "1.6.3"
|
serialization = "1.6.3"
|
||||||
kotlinxImmutable = "0.3.8"
|
kotlinxImmutable = "0.3.8"
|
||||||
|
|
||||||
kotlinCompilerExtension = "1.5.14"
|
# AndroidX / Google
|
||||||
|
|
||||||
coreKtx = "1.10.1"
|
coreKtx = "1.10.1"
|
||||||
appcompat = "1.6.1"
|
appcompat = "1.6.1"
|
||||||
material = "1.10.0"
|
activityCompose = "1.8.0"
|
||||||
activity = "1.8.0"
|
|
||||||
constraintlayout = "2.1.4"
|
|
||||||
compose-bom = "2024.10.00"
|
|
||||||
hilt-nav-compose = "1.2.0"
|
|
||||||
navigation = "2.8.5"
|
navigation = "2.8.5"
|
||||||
paging = "3.3.2"
|
paging = "3.3.2"
|
||||||
lifecycle = "2.7.0"
|
composeBom = "2024.10.00"
|
||||||
|
material = "1.13.0"
|
||||||
|
|
||||||
|
# DI
|
||||||
hilt = "2.50"
|
hilt = "2.50"
|
||||||
|
hiltNavCompose = "1.2.0"
|
||||||
|
|
||||||
|
# Network
|
||||||
retrofit = "2.11.0"
|
retrofit = "2.11.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
moshi = "1.15.1"
|
moshi = "1.15.1"
|
||||||
jsoup = "1.17.2"
|
jsoup = "1.17.2"
|
||||||
|
|
||||||
|
# DB
|
||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
junit = "4.13.2"
|
|
||||||
junitVersion = "1.3.0"
|
# Images
|
||||||
espressoCore = "3.7.0"
|
coil = "2.7.0"
|
||||||
materialVersion = "1.13.0"
|
|
||||||
junitJunit = "4.13.2"
|
|
||||||
androidxJunit = "1.3.0"
|
|
||||||
espressoCoreVersion = "3.7.0"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# KotlinX
|
# 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-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-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" }
|
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-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
|
|
||||||
# Compose (BOM)
|
# 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-ui = { module = "androidx.compose.ui:ui" }
|
||||||
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||||
compose-runtime = { module = "androidx.compose.runtime:runtime" }
|
compose-runtime = { module = "androidx.compose.runtime:runtime" }
|
||||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
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-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||||
androidx-navigation-common = { module = "androidx.navigation:navigation-common-ktx", 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-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", 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
|
# Network
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", 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 = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
|
||||||
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
|
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
|
||||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
|
|
||||||
# Room
|
# Room
|
||||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
room-compiler = { module = "androidx.room:room-compiler", 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
|
# Debug
|
||||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user