13 Commits

Author SHA1 Message Date
Shcherbatykh Oleg
06fa00eb8f Start impl base news ui 2026-03-02 11:27:28 +03:00
075dac1fc7 refactoring 2026-02-27 18:23:36 +03:00
bc07ea421b Impl base app theme, change statusbar color 2026-02-27 18:18:23 +03:00
Shcherbatykh Oleg
59c869d539 fix locale in parser 2026-02-27 11:37:49 +03:00
Shcherbatykh Oleg
082c4f7973 enable news func 2026-02-27 11:35:37 +03:00
Shcherbatykh Oleg
c2efbd1f75 Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-ui 2026-02-27 11:32:55 +03:00
Shcherbatykh Oleg
0482f03e09 Temp. disabled news 2026-02-27 11:32:36 +03:00
Shcherbatykh Oleg
aff43c61a0 update base and schedule url 2026-02-27 11:31:22 +03:00
Shcherbatykh Oleg
2987289581 Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-ui 2026-02-27 11:22:18 +03:00
Shcherbatykh Oleg
aaed01bd12 Change icon type 2026-02-27 11:21:03 +03:00
Shcherbatykh Oleg
39ff86c8a0 Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-ui 2026-02-27 11:03:07 +03:00
Shcherbatykh Oleg
993ab0a0b1 merge fix 2026-02-27 11:01:47 +03:00
Shcherbatykh Oleg
1f6e7b8ac0 update app navigation 2026-02-27 11:00:07 +03:00
47 changed files with 819 additions and 619 deletions

View File

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

View File

@@ -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 {
TSUDeskApp() TSUDeskTheme {
ConfigureSystemBars(
statusBarColor = Color.Transparent,
navigationBarColor = TSUDeskThemeExt.colors.brand,
darkIcons = false
)
TSUDeskApp()
}
} }
} }
} }

View File

@@ -14,7 +14,7 @@ private const val BASE_TIMEOUT = 30L
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppConfigModule { object AppConfigModule {
private const val BASE_URL_PROD = "https://tulsu.ru/schedule/queries/" private const val BASE_URL_PROD = "https://tulsu.ru/"
private const val BASE_URL_DEVELOP = "https://scherbatykh.ru/app/tsudesk/" private const val BASE_URL_DEVELOP = "https://scherbatykh.ru/app/tsudesk/"
@Provides @Provides

View File

@@ -1,37 +1,32 @@
package ru.fincode.tsudesk.app.presentation.main package ru.fincode.tsudesk.presentation.main
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import ru.fincode.tsudesk.core.navigation.AppRoute import ru.fincode.tsudesk.core.navigation.AppRoute
import ru.fincode.tsudesk.core.navigation.TopLevelDestination import ru.fincode.tsudesk.core.navigation.TopLevelDestination
import ru.fincode.tsudesk.core.navigation.route
fun selectedTopLevel(entry: NavBackStackEntry?): TopLevelDestination { fun selectedTopLevel(entry: NavBackStackEntry?): TopLevelDestination {
if (entry == null) return TopLevelDestination.SCHEDULE val dest: NavDestination = entry?.destination ?: return TopLevelDestination.SCHEDULE
return runCatching { return when {
when (entry.route<AppRoute>()) { dest.hasRoute<AppRoute.Schedule>() || dest.hasRoute<AppRoute.ScheduleDetails>() ->
AppRoute.Schedule, TopLevelDestination.SCHEDULE
is AppRoute.ScheduleDetails -> TopLevelDestination.SCHEDULE
AppRoute.News, dest.hasRoute<AppRoute.News>() || dest.hasRoute<AppRoute.NewsDetails>() ->
is AppRoute.NewsDetails -> TopLevelDestination.NEWS TopLevelDestination.NEWS
AppRoute.Progress -> TopLevelDestination.PROGRESS dest.hasRoute<AppRoute.Progress>() ->
TopLevelDestination.PROGRESS
AppRoute.Settings -> TopLevelDestination.SETTINGS dest.hasRoute<AppRoute.Settings>() ->
TopLevelDestination.SETTINGS
else -> TopLevelDestination.SCHEDULE else -> TopLevelDestination.SCHEDULE
} }
}.getOrDefault(TopLevelDestination.SCHEDULE)
} }
fun shouldShowBottomBar(entry: NavBackStackEntry?): Boolean { fun shouldShowBottomBar(entry: NavBackStackEntry?): Boolean {
if (entry == null) return false val dest: NavDestination = entry?.destination ?: return false
return !dest.hasRoute<AppRoute.Splash>()
return runCatching {
when (entry.route<AppRoute>()) {
AppRoute.Splash -> false
else -> true
}
}.getOrDefault(true)
} }

View File

@@ -1,59 +1,47 @@
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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.app.presentation.main.selectedTopLevel
import ru.fincode.tsudesk.app.presentation.main.shouldShowBottomBar
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 = painterResource(R.drawable.ic_progress) val scheduleIcon = Icons.Rounded.CalendarToday
val newsIcon = painterResource(R.drawable.ic_news) val newsIcon = Icons.Rounded.Newspaper
val progressIcon = painterResource(R.drawable.ic_progress) val progressIcon = Icons.Rounded.School
val settingsIcon = painterResource(R.drawable.ic_progress) val settingsIcon = Icons.Rounded.Settings
val items = listOf( val items = listOf(
TopLevelItem( TopLevelItem(TopLevelDestination.SCHEDULE, R.string.tab_schedule, scheduleIcon),
destination = TopLevelDestination.SCHEDULE, TopLevelItem(TopLevelDestination.NEWS, R.string.tab_news, newsIcon),
labelRes = R.string.tab_schedule, TopLevelItem(TopLevelDestination.PROGRESS, R.string.tab_progress, progressIcon),
icon = scheduleIcon TopLevelItem(TopLevelDestination.SETTINGS, R.string.tab_settings, settingsIcon),
),
TopLevelItem(
destination = TopLevelDestination.NEWS,
labelRes = R.string.tab_news,
icon = newsIcon
),
TopLevelItem(
destination = TopLevelDestination.PROGRESS,
labelRes = R.string.tab_progress,
icon = progressIcon
),
TopLevelItem(
destination = TopLevelDestination.SETTINGS,
labelRes = R.string.tab_settings,
icon = settingsIcon
),
) )
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
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 ->

View File

@@ -1,12 +1,11 @@
package ru.fincode.tsudesk.presentation.main package ru.fincode.tsudesk.presentation.main
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import ru.fincode.tsudesk.core.navigation.TopLevelDestination import ru.fincode.tsudesk.core.navigation.TopLevelDestination
data class TopLevelItem( data class TopLevelItem(
val destination: TopLevelDestination, val destination: TopLevelDestination,
@StringRes val labelRes: Int, @StringRes val labelRes: Int,
val icon: Painter, val icon: ImageVector
) )

View File

@@ -1,19 +1,20 @@
package ru.fincode.tsudesk.presentation.navigation package ru.fincode.tsudesk.presentation.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController
import ru.fincode.tsudesk.presentation.main.MainScaffold
import ru.fincode.tsudesk.core.navigation.AppRoute import ru.fincode.tsudesk.core.navigation.AppRoute
import ru.fincode.tsudesk.core.navigation.navigateRoute import ru.fincode.tsudesk.core.navigation.navigateRoute
import ru.fincode.tsudesk.feature.news.presentation.screen.NewsRoute
//import ru.fincode.tsudesk.feature.news.presentation.screen.NewsRoute
import ru.fincode.tsudesk.presentation.main.MainScaffold
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleRoute import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleRoute
import ru.fincode.tsudesk.feature.splash.presentation.screen.SplashRoute import ru.fincode.tsudesk.feature.splash.presentation.screen.SplashRoute
@Composable @Composable
fun AppNavHost( fun AppNavHost(
navController: NavHostController, navController: NavHostController,
@@ -33,51 +34,45 @@ fun AppNavHost(
) )
} }
navigation<AppRoute.Main>(startDestination = AppRoute.Schedule) { composable<AppRoute.Main> {
val tabsNavController = rememberNavController()
composable<AppRoute.Schedule> { MainScaffold(
MainScaffold(navController) { innerModifier -> navController = tabsNavController
ScheduleRoute( ) { innerModifier ->
modifier = innerModifier, MainTabsNavHost(
// onOpenDetails = { lessonId -> navController = tabsNavController,
// navController.navigateRoute(AppRoute.ScheduleDetails(lessonId)) modifier = innerModifier
// } )
)
}
} }
}
}
}
composable<AppRoute.News> { @Composable
MainScaffold(navController) { innerModifier: Modifier -> private fun MainTabsNavHost(
// NewsScreen( navController: NavHostController,
// modifier = innerModifier, modifier: Modifier = Modifier,
// onOpenDetails = { id -> ) {
// navController.navigateRoute(AppRoute.NewsDetails(id)) NavHost(
// } navController = navController,
// ) startDestination = AppRoute.Schedule,
} modifier = modifier
} ) {
composable<AppRoute.Schedule> {
ScheduleRoute(modifier = Modifier.fillMaxSize())
}
composable<AppRoute.Progress> { composable<AppRoute.News> {
MainScaffold(navController) { innerModifier: Modifier -> NewsRoute(modifier = Modifier.fillMaxSize())
// ProgressScreen(modifier = innerModifier) }
}
}
composable<AppRoute.Settings> { composable<AppRoute.Progress> {
MainScaffold(navController) { innerModifier -> // ProgressRoute(modifier = Modifier)
// SettingsRoute(modifier = innerModifier) }
}
} composable<AppRoute.Settings> {
// // SettingsRoute(modifier = Modifier)
// composable<AppRoute.ScheduleDetails> { entry ->
// val args = entry.route<AppRoute.ScheduleDetails>()
// ScheduleDetailsScreen(lessonId = args.lessonId)
// }
//
// composable<AppRoute.NewsDetails> { entry ->
// val args = entry.route<AppRoute.NewsDetails>()
// NewsDetailsScreen(id = args.id)
// }
} }
} }
} }

View File

@@ -3,10 +3,12 @@
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:pathData="M12,3L1,9l11,6 9,-4.91V17h2V9L12,3z" android:pathData="M12,3L1,9l11,6 9,-4.91V17h2V9L12,3z"
android:fillColor="?attr/colorControlNormal"/> android:fillColor="#FF000000"/>
<path <path
android:pathData="M5,12.18V17c0,2.21 3.58,4 7,4s7,-1.79 7,-4v-4.82l-7,3.82 -7,-3.82z" android:pathData="M5,12.18V17c0,2.21 3.58,4 7,4s7,-1.79 7,-4v-4.82l-7,3.82 -7,-3.82z"
android:fillColor="?attr/colorControlNormal"/> android:fillColor="#FF000000"/>
</vector> </vector>

View File

@@ -13,7 +13,6 @@ import ru.fincode.tsudesk.core.database.api.schedule.ScheduleDao
LessonCacheEntity::class LessonCacheEntity::class
], ],
version = 2, version = 2,
exportSchema = true
) )
@TypeConverters(StringListConverter::class) @TypeConverters(StringListConverter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package ru.fincode.tsudesk.core.ui.components
import androidx.compose.ui.graphics.vector.ImageVector
data class BottomBarItem(
val key: String,
val label: String,
val icon: ImageVector,
)

View File

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

View File

@@ -1,17 +1,11 @@
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.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
data class BottomBarItem(
val key: String,
val label: String,
val icon: Painter,
)
@Composable @Composable
fun TsudeskBottomBar( fun TsudeskBottomBar(
@@ -21,19 +15,22 @@ fun TsudeskBottomBar(
) { ) {
NavigationBar { NavigationBar {
items.forEach { item -> items.forEach { item ->
val selected = item.key == selectedKey
NavigationBarItem( NavigationBarItem(
selected = item.key == selectedKey, selected = selected,
onClick = { onItemClick(item) }, onClick = { onItemClick(item) },
icon = { icon = {
Icon( Icon(
painter = item.icon, imageVector = item.icon,
contentDescription = item.label contentDescription = item.label,
tint = if (selected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
) )
}, },
label = { label = { Text(item.label) }
Text(text = item.label)
},
alwaysShowLabel = true
) )
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package ru.fincode.tsudesk.core.ui.theme
import androidx.compose.material3.Typography
val TSUDeskTypography = Typography()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("ru"))
fun parseArchivePage(html: String): List<NewsItem> {
val itemRegex = Regex(
pattern = """<div class="single-blog">([\s\S]*?)</div>\s*</div>\s*</div>""",
options = setOf(RegexOption.IGNORE_CASE)
)
return itemRegex.findAll(html).mapNotNull { match ->
val block = match.value
val dateString = findFirst(
block,
"""<span>\s*(\d{2}\.\d{2}\.\d{4})\s*</span>"""
) ?: return@mapNotNull null
val date = parseDate(dateString) ?: return@mapNotNull null
val category = findFirst(
block,
"""</ul>\s*<span>\s*([^<]+?)\s*</span>"""
)?.trim().orEmpty()
val (url, title) = findLinkAndTitle(block) ?: return@mapNotNull null
val id = extractIdFromUrl(url) ?: return@mapNotNull null
NewsItem(
id = id,
title = htmlUnescape(title.trim()),
url = url.trim(),
date = date,
category = htmlUnescape(category.trim())
)
}.toList()
}
private fun parseDate(raw: String): LocalDate? =
try {
LocalDate.parse(raw.trim(), dateFormatter)
} catch (_: DateTimeParseException) {
null
}
private fun findLinkAndTitle(block: String): Pair<String, String>? {
val linkRegex = Regex(
pattern = """<h3>\s*<a\s+href="([^"]+)">\s*([\s\S]*?)\s*</a>""",
options = setOf(RegexOption.IGNORE_CASE)
)
val m = linkRegex.find(block) ?: return null
val url = m.groupValues[1]
val title = stripTags(m.groupValues[2])
return url to title
}
private fun extractIdFromUrl(url: String): Long? {
val idRegex = Regex(""".*/news/all/(\d+).*""")
val m = idRegex.matchEntire(url) ?: return null
return m.groupValues[1].toLongOrNull()
}
private fun findFirst(text: String, pattern: String): String? =
Regex(pattern, RegexOption.IGNORE_CASE)
.find(text)
?.groupValues
?.getOrNull(1)
private fun stripTags(s: String): String =
s.replace(Regex("<[^>]*>"), " ")
.replace(Regex("\\s+"), " ")
.trim()
private fun htmlUnescape(s: String): String =
s.replace("&quot;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#39;", "'")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() item {
NewsHeader(
// Триггер догрузки: подошли к концу списка query = state.query,
val shouldLoadNext = onQueryChange = {
index >= state.items.lastIndex - 2 && viewModel.onEvent(NewsEvent.QueryChanged(it))
!state.isAppending &&
!state.endReached
if (shouldLoadNext) {
LaunchedEffect(item.id) {
onScrolledToEnd()
} }
} )
} }
// Footer: догрузка items(
item { count = pagingItems.itemCount,
if (state.isAppending) { key = { index -> pagingItems[index]?.id ?: "placeholder_$index" }
Column(modifier = Modifier.padding(16.dp)) { ) { index ->
CircularProgressIndicator()
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 @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,
shape = RoundedCornerShape(
bottomStart = 40.dp,
bottomEnd = 40.dp
)
)
.padding(20.dp)
) { ) {
Text( Text(
text = title, "Новости",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.headlineLarge,
color = colors.onPrimaryContainer
) )
Spacer(Modifier.height(6.dp))
Text( Text(
text = meta, "ТулГУ · Последние обновления",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp) 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
)
}
}
}
} }

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package ru.fincode.tsudesk.feature.schedule.data.remote
object ScheduleApiContract { object ScheduleApiContract {
object Path { object Path {
const val GET_SCHEDULE_METHOD = "GetSchedule.php" const val GET_SCHEDULE_METHOD = "schedule/queries/GetSchedule.php"
} }
object Query { object Query {

View File

@@ -1,18 +1,25 @@
package ru.fincode.tsudesk.feature.schedule.presentation.navigation package ru.fincode.tsudesk.feature.schedule.presentation.navigation
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import ru.fincode.tsudesk.core.navigation.AppRoute import ru.fincode.tsudesk.core.navigation.AppRoute
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleRoute import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleRoute
fun NavGraphBuilder.scheduleGraph( fun NavGraphBuilder.scheduleGraph(
modifier: Modifier = Modifier,
// на будущее:
// onOpenDetails: (lessonId: Long) -> Unit,
) { ) {
composable<AppRoute.Schedule> { composable<AppRoute.Schedule> {
ScheduleRoute() ScheduleRoute(
modifier = modifier,
// onOpenDetails = onOpenDetails
)
} }
// composable<AppRoute.ScheduleDetails> { backStackEntry -> // composable<AppRoute.ScheduleDetails> { entry ->
// val args = backStackEntry.toRoute<AppRoute.ScheduleDetails>() // val args = entry.toRoute<AppRoute.ScheduleDetails>()
// ScheduleDetailsRoute(lessonId = args.lessonId) // ScheduleDetailsRoute(lessonId = args.lessonId)
// } // }
} }

View File

@@ -1,4 +1,4 @@
package ru.fincode.tsudesk.feature.schedule.presentation package ru.fincode.tsudesk.feature.schedule.presentation.screen
sealed interface ScheduleAction { sealed interface ScheduleAction {

View File

@@ -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,62 +88,67 @@ 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)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Column(
Column(Modifier.weight(1f)) { Modifier
Text( // inset только для содержимого, фон остаётся до верха
text = "Расписание", .windowInsetsPadding(WindowInsets.statusBars)
color = MaterialTheme.colorScheme.onError, .padding(start = 16.dp, end = 16.dp, bottom = 14.dp)
fontSize = 20.sp, ) {
fontWeight = FontWeight.Bold Row(verticalAlignment = Alignment.CenterVertically) {
) Column(Modifier.weight(1f)) {
Text( Text(
text = "ТулГУ • Осенний семестр", text = "Расписание",
color = MaterialTheme.colorScheme.onError.copy(alpha = 0.8f), color = brand.onBrand,
fontSize = 11.sp, fontSize = 20.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Bold
)
Text(
text = "ТулГУ • Осенний семестр",
color = brand.onBrand.copy(alpha = 0.8f),
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
ModeToggle(
mode = state.viewMode,
onMode = { onIntent(ScheduleIntent.SetViewMode(it)) }
) )
} }
ModeToggle( Spacer(Modifier.height(12.dp))
mode = state.viewMode,
onMode = { onIntent(ScheduleIntent.SetViewMode(it)) } GroupSelector(
groupInput = state.groupInput,
recentGroups = state.recentGroups,
expanded = state.isGroupMenuExpanded,
isLoading = state.isLoading,
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
onSelectRecent = { group ->
onIntent(ScheduleIntent.SelectRecentGroup(group))
onIntent(ScheduleIntent.ApplyGroup)
},
onRemoveRecent = { onIntent(ScheduleIntent.RemoveRecentGroup(it)) },
onApply = { onIntent(ScheduleIntent.ApplyGroup) },
modifier = Modifier.fillMaxWidth()
) )
} }
Spacer(Modifier.height(12.dp))
GroupSelector(
groupInput = state.groupInput,
recentGroups = state.recentGroups,
expanded = state.isGroupMenuExpanded,
isLoading = state.isLoading,
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
// выбор из списка -> сразу загрузка
onSelectRecent = { group ->
onIntent(ScheduleIntent.SelectRecentGroup(group))
onIntent(ScheduleIntent.ApplyGroup)
},
onRemoveRecent = { onIntent(ScheduleIntent.RemoveRecentGroup(it)) },
onApply = { onIntent(ScheduleIntent.ApplyGroup) },
modifier = Modifier.fillMaxWidth()
)
} }
} }
@@ -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
} }

View File

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

View File

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