Merge branch 'develop' of https://github.com/finocd2la/TSUDesk into feature/news-base

# Conflicts:
#	feature/news/build.gradle.kts
This commit is contained in:
Shcherbatykh Oleg
2026-02-24 15:49:48 +03:00
27 changed files with 1418 additions and 177 deletions

View File

@@ -12,10 +12,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "ru.fincode.tsudesk" applicationId = "ru.fincode.tsudesk"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt() versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get() versionName = libs.versions.versionName.get()
} }
@@ -29,6 +27,7 @@ android {
) )
} }
} }
val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get()) val jvm = JavaVersion.toVersion(libs.versions.jvmTarget.get())
compileOptions { compileOptions {
sourceCompatibility = jvm sourceCompatibility = jvm
@@ -37,36 +36,37 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = jvm.toString() jvmTarget = jvm.toString()
} }
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
}
} }
kapt { kapt {
correctErrorTypes = true correctErrorTypes = true
} }
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
dependencies {
// Compose // Compose
implementation(libs.androidx.activity.compose)
implementation(platform(libs.compose.bom)) implementation(platform(libs.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)
// Navigation Compose // Navigation Compose
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.common)
kapt(libs.hilt.compiler) // DI: Hilt
implementation(libs.hilt.android) implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.okhttp) // Modules
implementation(libs.retrofit)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.config) implementation(projects.core.config)
implementation(projects.core.navigation) implementation(projects.core.navigation)
@@ -76,4 +76,7 @@ dependencies {
implementation(projects.feature.schedule) implementation(projects.feature.schedule)
implementation(projects.feature.progress) implementation(projects.feature.progress)
implementation(projects.feature.news) implementation(projects.feature.news)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
} }

View File

@@ -18,7 +18,7 @@ object AppConfigModule {
@Provides @Provides
@Singleton @Singleton
fun provideAppConfig(): AppConfig { fun provideAppConfig(): AppConfig {
val baseUrl = if (BuildConfig.DEBUG) BASE_URL_DEVELOP else BASE_URL_PROD val baseUrl = if (BuildConfig.DEBUG) BASE_URL_PROD else BASE_URL_PROD
return AppConfig( return AppConfig(
isDebug = BuildConfig.DEBUG, isDebug = BuildConfig.DEBUG,
baseUrl = baseUrl, baseUrl = baseUrl,

View File

@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<!-- Base application theme. --> <style name="Theme.TSUDesk" parent="android:Theme.Material.NoActionBar" />
<style name="Theme.TSUDesk" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources> </resources>

View File

@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources>
<!-- Base application theme. --> <style name="Theme.TSUDesk" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.TSUDesk" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources> </resources>

View File

@@ -1,7 +1,12 @@
package ru.fincode.tsudesk.core.common.model package ru.fincode.tsudesk.core.common.model
sealed interface AppError { sealed interface AppError {
data object NoInternet : AppError data object NoInternet : AppError
data object Timeout : AppError data object Timeout : AppError
/** Временный сбой соединения (EOF / unexpected end of stream / reset) */
data object Temporary : AppError
data class Http(val code: Int) : AppError data class Http(val code: Int) : AppError
data class Unknown(val message: String? = null) : AppError data class Unknown(val message: String? = null) : AppError
} }

View File

@@ -3,10 +3,15 @@ package ru.fincode.tsudesk.core.common.model
sealed interface DataResult<out T> { sealed interface DataResult<out T> {
data class Data<T>( data class Data<T>(
val data: T, val refreshedFromNetwork: Boolean val data: T,
val refreshedFromNetwork: Boolean
) : DataResult<T> ) : DataResult<T>
data class Error<T>( data class Error<T>(
val error: AppError, val data: T? = null, val cause: Throwable? = null val error: AppError,
val data: T? = null,
val cause: Throwable? = null
) : DataResult<T> ) : DataResult<T>
data object Loading : DataResult<Nothing>
} }

View File

@@ -45,11 +45,11 @@ dependencies {
api(libs.okhttp) api(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.converter.gson) implementation(libs.retrofit.gson)
api(libs.moshi) api(libs.moshi)
api(libs.moshiKotlin) api(libs.moshi.kotlin)
api(libs.retrofitMoshi) api(libs.retrofit.moshi)
implementation(projects.core.common) implementation(projects.core.common)
} }

View File

@@ -6,8 +6,13 @@ import ru.fincode.tsudesk.core.network.model.NetworkError
import ru.fincode.tsudesk.core.network.model.NetworkMeta import ru.fincode.tsudesk.core.network.model.NetworkMeta
import ru.fincode.tsudesk.core.network.model.NetworkResult import ru.fincode.tsudesk.core.network.model.NetworkResult
import ru.fincode.tsudesk.core.network.model.NetworkResult.Success import ru.fincode.tsudesk.core.network.model.NetworkResult.Success
import java.io.EOFException
import java.io.IOException import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException
suspend inline fun <T> apiCall( suspend inline fun <T> apiCall(
crossinline block: suspend () -> Response<T> crossinline block: suspend () -> Response<T>
@@ -51,8 +56,23 @@ suspend inline fun <T> apiCall(
} }
fun Throwable.toNetworkError(meta: NetworkMeta): NetworkError = when (this) { fun Throwable.toNetworkError(meta: NetworkMeta): NetworkError = when (this) {
is SocketTimeoutException -> NetworkError.Timeout(meta)
is IOException -> NetworkError.NoInternet(meta) is UnknownHostException,
is ConnectException,
is NoRouteToHostException -> NetworkError.NoInternet(meta)
is SocketTimeoutException,
is InterruptedIOException -> NetworkError.Timeout(meta)
is EOFException -> NetworkError.Temporary(meta)
is HttpException -> NetworkError.Http(code(), meta) is HttpException -> NetworkError.Http(code(), meta)
is IOException -> {
val msg = message?.lowercase().orEmpty()
if ("unexpected end of stream" in msg || "eof" in msg) NetworkError.Temporary(meta)
else NetworkError.Temporary(meta) // любые IO — временные, не "NoInternet"
}
else -> NetworkError.Unknown(this, meta) else -> NetworkError.Unknown(this, meta)
} }

View File

@@ -0,0 +1,77 @@
package ru.fincode.tsudesk.core.network.interceptor
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import kotlin.math.min
class RetryInterceptor(
private val maxAttempts: Int = 3,
private val baseDelayMs: Long = 300L
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// На всякий: ретраим только идемпотентные методы
val method = request.method.uppercase()
val retryAllowed = method == "GET" || method == "HEAD"
var attempt = 1
var lastException: IOException? = null
var lastResponse: Response? = null
while (attempt <= maxAttempts) {
// закрываем предыдущий response, чтобы не текли ресурсы
lastResponse?.close()
lastResponse = null
try {
val response = chain.proceed(request)
if (!retryAllowed) return response
if (!response.shouldRetry()) {
return response
}
// Нужно ретраить — подождём и попробуем снова
if (attempt == maxAttempts) return response
sleepBackoff(attempt)
attempt++
continue
} catch (e: IOException) {
lastException = e
if (!retryAllowed || attempt == maxAttempts) throw e
sleepBackoff(attempt)
attempt++
}
}
// Теоретически не должны попасть сюда
lastResponse?.let { return it }
lastException?.let { throw it }
throw IOException("RetryInterceptor: exhausted attempts without response/exception")
}
private fun Response.shouldRetry(): Boolean {
return when (code) {
408, 429 -> true
in 500..599 -> true
else -> false
}
}
private fun sleepBackoff(attempt: Int) {
val delay = baseDelayMs * min(attempt.toLong(), 2L)
try {
Thread.sleep(delay)
} catch (_: InterruptedException) {
}
}
}

View File

@@ -7,6 +7,12 @@ sealed class NetworkError(open val meta: NetworkMeta) {
data class NoInternet(override val meta: NetworkMeta) : NetworkError(meta) data class NoInternet(override val meta: NetworkMeta) : NetworkError(meta)
data class Timeout(override val meta: NetworkMeta) : NetworkError(meta) data class Timeout(override val meta: NetworkMeta) : NetworkError(meta)
/**
* EOF / unexpected end of stream / reset / обрыв соединения.
* Это НЕ NoInternet.
*/
data class Temporary(override val meta: NetworkMeta) : NetworkError(meta)
data class Http( data class Http(
val code: Int, val code: Int,
override val meta: NetworkMeta override val meta: NetworkMeta
@@ -20,6 +26,7 @@ sealed class NetworkError(open val meta: NetworkMeta) {
fun toAppError(): AppError = when (this) { fun toAppError(): AppError = when (this) {
is NoInternet -> AppError.NoInternet is NoInternet -> AppError.NoInternet
is Timeout -> AppError.Timeout is Timeout -> AppError.Timeout
is Temporary -> AppError.Temporary
is Http -> AppError.Http(code) is Http -> AppError.Http(code)
is Unknown -> AppError.Unknown(throwable.message) is Unknown -> AppError.Unknown(throwable.message)
} }

View File

@@ -35,7 +35,6 @@ android {
} }
} }
dependencies { dependencies {
implementation(libs.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material)
} }

View File

@@ -65,5 +65,5 @@ dependencies {
implementation(projects.core.config) implementation(projects.core.config)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.navigation) implementation(projects.core.navigation)
implementation("org.jsoup:jsoup:1.17.2") implementation(libs.jsoup)
} }

View File

@@ -31,7 +31,6 @@ android {
} }
} }
dependencies { dependencies {
implementation(libs.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material)
} }

View File

@@ -19,7 +19,8 @@ android {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
) )
} }
} }
@@ -29,40 +30,45 @@ android {
sourceCompatibility = jvm sourceCompatibility = jvm
targetCompatibility = jvm targetCompatibility = jvm
} }
kotlinOptions { kotlinOptions { jvmTarget = jvm.toString() }
jvmTarget = jvm.toString()
}
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.kotlinCompilerExtension.get()
}
} }
kapt {
correctErrorTypes = true
}
dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
// Compose kapt { correctErrorTypes = true }
dependencies {
// Kotlin
implementation(libs.kotlinx.collections.immutable)
// Compose UI
implementation(platform(libs.compose.bom)) implementation(platform(libs.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)
// Navigation Compose // Navigation (Compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.common)
kapt(libs.hilt.compiler) // DI
implementation(libs.hilt.android) implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose) implementation(libs.hilt.navigation.compose)
implementation(projects.core.network) // Core modules
implementation(projects.core.database)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.config) implementation(projects.core.config)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(projects.core.navigation) implementation(projects.core.navigation)
// Data modules used by feature (если feature реально ходит в сеть/бд)
implementation(projects.core.network)
implementation(projects.core.database)
} }

View File

@@ -22,15 +22,18 @@ class ScheduleRepositoryImpl @Inject constructor(
override fun observeSchedule(type: ScheduleType): Flow<DataResult<ScheduleEntity>> = flow { override fun observeSchedule(type: ScheduleType): Flow<DataResult<ScheduleEntity>> = flow {
val scheduleKey = type.toCacheKey() val scheduleKey = type.toCacheKey()
val cached = local.observeSchedule(scheduleKey).first() val cached = local.observeSchedule(scheduleKey).first()
if (cached != null) { if (cached != null) {
emit(DataResult.Data(cached, refreshedFromNetwork = false)) emit(DataResult.Data(cached, refreshedFromNetwork = false))
if ((System.currentTimeMillis() - cached.timestamp) < CACHE_TTL_MS) return@flow if ((System.currentTimeMillis() - cached.timestamp) < CACHE_TTL_MS) return@flow
} else {
emit(DataResult.Loading)
} }
val networkResult = loadSchedule(type) val networkResult = loadScheduleDto(type).map(mapper::invoke)
if (networkResult is NetworkResult.Error) { if (networkResult is NetworkResult.Error) {
if (cached != null) return@flow
emit(DataResult.Error(networkResult.error.toAppError())) emit(DataResult.Error(networkResult.error.toAppError()))
return@flow return@flow
} }
@@ -44,14 +47,15 @@ class ScheduleRepositoryImpl @Inject constructor(
} }
} }
private suspend fun loadSchedule(type: ScheduleType): NetworkResult<ScheduleEntity> = private suspend fun loadScheduleDto(
type: ScheduleType
): NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> =
when (type) { when (type) {
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number).map(mapper::invoke) is ScheduleType.Group -> remote.loadScheduleByGroup(type.number)
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name)
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name).map(mapper::invoke)
} }
private companion object { private companion object {
private const val CACHE_TTL_MS: Long = 1;//24L * 60L * 60L * 1000L // 1 day private const val CACHE_TTL_MS: Long = 3L * 24L * 60L * 60L * 1000L // 3 дня
} }
} }

View File

@@ -0,0 +1,10 @@
package ru.fincode.tsudesk.feature.schedule.presentation
sealed interface ScheduleAction {
/** Пользователь выбрал группу в выпадающем списке — сразу грузим */
data class GroupSelected(val group: String) : ScheduleAction
/** Кнопка "Показать" (можно оставить как "Повторить") */
data object ApplyGroupClicked : ScheduleAction
}

View File

@@ -1,11 +0,0 @@
package ru.fincode.tsudesk.feature.schedule.presentation.model
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
data class ScheduleUiState(
val isLoading: Boolean = false,
val data: ScheduleEntity? = null,
val errorMessage: String? = null,
val title: String = "Schedule",
val refreshedFromNetwork: Boolean = false
)

View File

@@ -0,0 +1,62 @@
package ru.fincode.tsudesk.feature.schedule.presentation.screen
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
import ru.fincode.tsudesk.feature.schedule.presentation.util.buildDayStrip
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
enum class ScheduleViewMode { DAY, WEEK }
data class ScheduleUiState(
val isLoading: Boolean = false,
val error: String? = null,
val selectedType: ScheduleType = ScheduleType.Group("220631"),
val groupInput: String = "220631",
val recentGroups: List<String> = listOf("220631", "220632", "220633"),
val isGroupMenuExpanded: Boolean = false,
val viewMode: ScheduleViewMode = ScheduleViewMode.DAY,
val selectedDate: String = todayString(),
val dayStrip: List<String> = buildDayStrip(todayString()),
val raw: DataResult<ScheduleEntity>? = null,
val dayLessons: List<UiLesson> = emptyList(),
val weekGrouped: List<UiWeekDay> = emptyList(),
)
sealed interface ScheduleIntent {
data class ChangeGroupInput(val value: String) : ScheduleIntent
data object ApplyGroup : ScheduleIntent
data class SetGroupMenuExpanded(val expanded: Boolean) : ScheduleIntent
data class SelectRecentGroup(val number: String) : ScheduleIntent
data class RemoveRecentGroup(val number: String) : ScheduleIntent
data class SelectDate(val date: String) : ScheduleIntent
data class SetViewMode(val mode: ScheduleViewMode) : ScheduleIntent
data object Retry : ScheduleIntent
}
data class UiLesson(
val id: String,
val time: String,
val title: String,
val teacher: String,
val room: String,
val badgeText: String,
val badgeKind: BadgeKind,
val isNow: Boolean,
)
enum class BadgeKind { INFO, PRIMARY, LAB, MUTED }
data class UiWeekDay(
val date: String,
val title: String,
val lessons: List<UiLesson>,
)

View File

@@ -3,14 +3,15 @@ package ru.fincode.tsudesk.feature.schedule.presentation.screen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleViewModel
import ru.fincode.tsudesk.feature.schedule.presentation.ui.ScheduleScreen
@Composable @Composable
fun ScheduleRoute( fun ScheduleRoute(
viewModel: ScheduleViewModel = hiltViewModel() viewModel: ScheduleViewModel = hiltViewModel()
) { ) {
val state = viewModel.state.collectAsStateWithLifecycle().value val state = viewModel.state.collectAsStateWithLifecycle().value
ScheduleScreen(state = state)
ScheduleScreen(
state = state,
onIntent = viewModel::onIntent
)
} }

View File

@@ -1,27 +1,634 @@
package ru.fincode.tsudesk.feature.schedule.presentation.ui package ru.fincode.tsudesk.feature.schedule.presentation.screen
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.border
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.foundation.horizontalScroll
import androidx.compose.material3.Text import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.*
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.graphics.Color import androidx.compose.ui.draw.clip
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.feature.schedule.presentation.*
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
@Composable @Composable
fun ScheduleScreen( fun ScheduleScreen(
state: ScheduleUiState state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit,
modifier: Modifier = Modifier
) { ) {
Box( Column(modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), ScheduleHeader(state, onIntent)
contentAlignment = Alignment.Center
when (state.viewMode) {
ScheduleViewMode.DAY -> DayPicker(state, onIntent)
ScheduleViewMode.WEEK -> WeekHeaderStub(state)
}
ScheduleContent(
state = state,
onIntent = onIntent,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ScheduleHeader(
state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit
) {
val shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
val headerBrush = Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.error,
MaterialTheme.colorScheme.error.copy(alpha = 0.75f)
)
)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(headerBrush)
.padding(top = 20.dp, start = 16.dp, end = 16.dp, bottom = 14.dp)
) { ) {
if (state.isLoading) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator() Column(Modifier.weight(1f)) {
Text(
text = "Расписание",
color = MaterialTheme.colorScheme.onError,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "ТулГУ • Осенний семестр",
color = MaterialTheme.colorScheme.onError.copy(alpha = 0.8f),
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
ModeToggle(
mode = state.viewMode,
onMode = { onIntent(ScheduleIntent.SetViewMode(it)) }
)
}
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()
)
}
}
@Composable
private fun ModeToggle(
mode: ScheduleViewMode,
onMode: (ScheduleViewMode) -> Unit
) {
val containerShape = RoundedCornerShape(12.dp)
Row(
Modifier
.background(MaterialTheme.colorScheme.onError.copy(alpha = 0.18f), containerShape)
.padding(4.dp)
) {
SegButton(
text = "День",
selected = mode == ScheduleViewMode.DAY,
onClick = { onMode(ScheduleViewMode.DAY) }
)
SegButton(
text = "Неделя",
selected = mode == ScheduleViewMode.WEEK,
onClick = { onMode(ScheduleViewMode.WEEK) }
)
}
}
@Composable
private fun SegButton(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
TextButton(
onClick = onClick,
shape = RoundedCornerShape(10.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
colors = ButtonDefaults.textButtonColors(
containerColor = if (selected) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onError.copy(
alpha = 0f
),
contentColor = if (selected) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onError.copy(
alpha = 0.85f
)
)
) {
Text(text = text, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun GroupSelector(
groupInput: String,
recentGroups: List<String>,
expanded: Boolean,
isLoading: Boolean,
onExpanded: (Boolean) -> Unit,
onValueChange: (String) -> Unit,
onSelectRecent: (String) -> Unit,
onRemoveRecent: (String) -> Unit,
onApply: () -> Unit,
modifier: Modifier = Modifier
) {
val keyboard = LocalSoftwareKeyboardController.current
Column(modifier) {
OutlinedTextField(
value = groupInput,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = true,
label = { Text("Группа") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
keyboard?.hide()
onExpanded(false)
if (groupInput.isNotBlank()) onApply()
}
),
trailingIcon = {
IconButton(onClick = { onExpanded(!expanded) }) {
Icon(
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = if (expanded) "Свернуть список" else "Развернуть список",
tint = MaterialTheme.colorScheme.onError
)
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.35f),
unfocusedBorderColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.25f),
focusedLabelColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.85f),
unfocusedLabelColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.7f),
focusedTextColor = MaterialTheme.colorScheme.onError,
unfocusedTextColor = MaterialTheme.colorScheme.onError,
cursorColor = MaterialTheme.colorScheme.onError
)
)
// ✅ Меню РЕАЛЬНО под полем, не перекрывает, фокус в поле работает
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpanded(false) },
modifier = Modifier.fillMaxWidth()
) {
if (recentGroups.isEmpty()) {
DropdownMenuItem(
text = { Text("Нет сохранённых групп") },
onClick = { onExpanded(false) },
enabled = false
)
} else { } else {
Text(text = state.title, color = Color.Black) recentGroups.forEach { num ->
DropdownMenuItem(
onClick = {
onSelectRecent(num)
onExpanded(false)
},
text = {
Text(
text = num,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
// ✅ trailingIcon — крестик НЕ вызывает onClick элемента меню
trailingIcon = {
IconButton(
onClick = { onRemoveRecent(num) }
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Удалить"
)
}
}
)
}
}
}
Spacer(Modifier.height(8.dp))
Button(
onClick = {
keyboard?.hide()
onExpanded(false)
onApply()
},
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.onError,
contentColor = MaterialTheme.colorScheme.error,
disabledContainerColor = MaterialTheme.colorScheme.onError.copy(alpha = 0.6f),
disabledContentColor = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(vertical = 12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(10.dp))
Text("Загрузка…", fontWeight = FontWeight.Bold)
} else {
Text("Показать", fontWeight = FontWeight.Bold)
}
} }
} }
} }
@Composable
private fun DayPicker(
state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit
) {
val scroll = rememberScrollState()
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.horizontalScroll(scroll)
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
state.dayStrip.forEach { date ->
DayChip(
date = date,
selected = date == state.selectedDate,
onClick = { onIntent(ScheduleIntent.SelectDate(date)) }
)
}
}
}
@Composable
private fun DayChip(
date: String,
selected: Boolean,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(14.dp)
val bg = if (selected) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.surface
val fg =
if (selected) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onSurface
val border =
if (selected) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline.copy(
alpha = 0.3f
)
val dayNum = date.take(2)
val dow = dowRuShort(date)
Surface(
onClick = onClick,
shape = shape,
color = bg,
tonalElevation = if (selected) 2.dp else 0.dp,
modifier = Modifier
.size(width = 64.dp, height = 80.dp)
.border(1.dp, border, shape)
) {
Column(
Modifier
.fillMaxSize()
.padding(vertical = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
dow,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = fg.copy(alpha = 0.85f)
)
Text(dayNum, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = fg)
if (selected) {
Spacer(Modifier.height(4.dp))
Box(
Modifier
.size(6.dp)
.clip(RoundedCornerShape(999.dp))
.background(fg)
)
}
}
}
}
@Composable
private fun WeekHeaderStub(state: ScheduleUiState) {
val start = state.dayStrip.firstOrNull()
val end = state.dayStrip.lastOrNull()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = when {
start != null && end != null -> "Неделя: $start$end"
else -> "Неделя"
},
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun ScheduleContent(
state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.then(modifier)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// ✅ неблокирующий индикатор обновления (если уже есть данные)
if (state.isLoading && state.raw != null) {
item {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
}
}
if (state.error != null) {
item {
Spacer(Modifier.height(12.dp))
ErrorBlock(message = state.error, onRetry = { onIntent(ScheduleIntent.Retry) })
}
return@LazyColumn
}
if (state.isLoading && state.raw == null) {
item {
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(Modifier.fillMaxWidth())
}
return@LazyColumn
}
when (state.viewMode) {
ScheduleViewMode.DAY -> {
item {
Text(
text = dayTitle(state.selectedDate),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
if (state.dayLessons.isEmpty()) {
item { EmptyBlock("На выбранный день пар нет.") }
} else {
items(items = state.dayLessons, key = { it.id }) { lesson ->
LessonCard(lesson)
}
}
}
ScheduleViewMode.WEEK -> {
state.weekGrouped.forEach { day ->
item {
Text(
text = day.title,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
if (day.lessons.isEmpty()) {
item { EmptyInline("Нет занятий") }
} else {
items(items = day.lessons, key = { it.id }) { lesson ->
LessonCard(lesson)
}
}
}
}
}
val refreshed = (state.raw as? DataResult.Data)?.refreshedFromNetwork
if (refreshed != null) {
item {
Text(
text = if (refreshed) "Источник: NETWORK" else "Источник: CACHE",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
fontSize = 12.sp
)
}
}
}
}
@Composable
private fun LessonCard(lesson: UiLesson) {
val shape = RoundedCornerShape(16.dp)
val borderColor =
if (lesson.isNow) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
val borderWidth = if (lesson.isNow) 2.dp else 1.dp
Surface(
shape = shape,
tonalElevation = if (lesson.isNow) 2.dp else 0.dp,
modifier = Modifier
.fillMaxWidth()
.border(borderWidth, borderColor, shape)
) {
Column(Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = lesson.time,
fontWeight = if (lesson.isNow) FontWeight.Bold else FontWeight.SemiBold,
color = if (lesson.isNow) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f)
)
if (lesson.isNow) {
Spacer(Modifier.width(8.dp))
AssistChip(
onClick = {},
label = { Text("СЕЙЧАС", fontWeight = FontWeight.Black) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f),
labelColor = MaterialTheme.colorScheme.error
)
)
}
Spacer(Modifier.weight(1f))
LessonBadge(text = lesson.badgeText, kind = lesson.badgeKind)
}
Spacer(Modifier.height(8.dp))
Text(
text = lesson.title,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(10.dp))
Row(Modifier.fillMaxWidth()) {
Text(
text = lesson.teacher,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = lesson.room,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable
private fun LessonBadge(text: String, kind: BadgeKind) {
val (bg, fg) = when (kind) {
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.LAB -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f) to MaterialTheme.colorScheme.tertiary
BadgeKind.MUTED -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(color = bg, contentColor = fg, shape = RoundedCornerShape(8.dp)) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(14.dp)) {
Text("Ошибка", fontWeight = FontWeight.Bold)
Spacer(Modifier.height(6.dp))
Text(message)
Spacer(Modifier.height(10.dp))
Button(onClick = onRetry) { Text("Повторить") }
}
}
}
@Composable
private fun EmptyBlock(text: String) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = text,
modifier = Modifier.padding(14.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun EmptyInline(text: String) {
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
fontSize = 12.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
}
private fun dayTitle(date: String): String {
val prefix = if (date == todayString()) "Сегодня" else "День"
return "$prefix, $date"
}

View File

@@ -0,0 +1,177 @@
package ru.fincode.tsudesk.feature.schedule.presentation.screen
import ru.fincode.tsudesk.feature.schedule.domain.model.LessonEntity
import ru.fincode.tsudesk.feature.schedule.domain.model.LessonType
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
import java.util.Calendar
object ScheduleUiMapper {
/**
* Маппинг уроков на выбранный день (dd.MM.yyyy)
*/
fun mapDayLessons(entity: ScheduleEntity, selectedDate: String): List<UiLesson> {
val today = todayString()
val nowMinutes = nowMinutes()
return entity.lessons
.asSequence()
.filter { it.date == selectedDate }
.sortedBy { startMinutesOrMax(it.time) }
.map { lesson ->
val (badgeText, badgeKind) = badgeFor(lesson)
UiLesson(
id = stableId(lesson),
time = normalizeTime(lesson.time),
title = lesson.name,
teacher = lesson.teacher,
room = lesson.room,
badgeText = badgeText,
badgeKind = badgeKind,
isNow = isNowLesson(
lessonDate = lesson.date,
lessonTime = lesson.time,
today = today,
nowMinutes = nowMinutes
)
)
}
.toList()
}
/**
* Группировка на неделю по списку dayStrip (порядок сохраняется)
*/
fun mapWeekGrouped(entity: ScheduleEntity, dayStrip: List<String>): List<UiWeekDay> {
val lessonsByDate = entity.lessons.groupBy { it.date }
val today = todayString()
val nowMinutes = nowMinutes()
return dayStrip.map { date ->
val lessons = (lessonsByDate[date].orEmpty())
.sortedBy { startMinutesOrMax(it.time) }
.map { lesson ->
val (badgeText, badgeKind) = badgeFor(lesson)
UiLesson(
id = stableId(lesson),
time = normalizeTime(lesson.time),
title = lesson.name,
teacher = lesson.teacher,
room = lesson.room,
badgeText = badgeText,
badgeKind = badgeKind,
isNow = isNowLesson(
lessonDate = lesson.date,
lessonTime = lesson.time,
today = today,
nowMinutes = nowMinutes
)
)
}
UiWeekDay(
date = date,
title = weekDayTitle(date),
lessons = lessons
)
}
}
private fun weekDayTitle(date: String): String {
// "Пн, 12.01.2026" (dowRuShort уже без java.time)
return "${dowRuShort(date)}, $date"
}
private fun badgeFor(lesson: LessonEntity): Pair<String, BadgeKind> {
return when (lesson.type) {
LessonType.LECTURE -> "Лекция" to BadgeKind.INFO
LessonType.PRACTICE -> "Практика" to BadgeKind.PRIMARY
LessonType.LAB -> "Лабораторная" to BadgeKind.LAB
LessonType.DEFAULT -> {
// fallback: если API прислал typeName — укоротим до 1 слова, иначе "Занятие"
val text = lesson.typeName.trim().ifBlank { "Занятие" }.let { tn ->
tn.split(' ', ',', ';').firstOrNull().orEmpty().ifBlank { "Занятие" }
}
text to BadgeKind.MUTED
}
}
}
/**
* Стабильный id для LazyColumn key
*/
private fun stableId(lesson: LessonEntity): String {
// date|time|name|room|teacher
return buildString {
append(lesson.date)
append('|')
append(lesson.time)
append('|')
append(lesson.name)
append('|')
append(lesson.room)
append('|')
append(lesson.teacher)
}
}
/**
* "08:30 — 10:00" / "08:30-10:00" / "08:30 - 10:00" -> "08:30 — 10:00"
*/
private fun normalizeTime(raw: String): String {
val (s, e) = parseStartEnd(raw) ?: return raw.trim()
return "$s$e"
}
/**
* Парсит начало/конец из строки времени.
* Возвращает null если не смогли распознать.
*/
private fun parseStartEnd(raw: String): Pair<String, String>? {
// Достаём 2 времени формата HH:mm
val matches = Regex("""\b([01]\d|2[0-3]):[0-5]\d\b""")
.findAll(raw)
.map { it.value }
.toList()
if (matches.size >= 2) return matches[0] to matches[1]
return null
}
private fun startMinutesOrMax(rawTime: String): Int {
val start = parseStartEnd(rawTime)?.first ?: return Int.MAX_VALUE
return toMinutes(start) ?: Int.MAX_VALUE
}
private fun toMinutes(hhmm: String): Int? {
val parts = hhmm.split(':')
if (parts.size != 2) return null
val h = parts[0].toIntOrNull() ?: return null
val m = parts[1].toIntOrNull() ?: return null
return h * 60 + m
}
private fun nowMinutes(): Int {
val c = Calendar.getInstance()
val h = c.get(Calendar.HOUR_OF_DAY)
val m = c.get(Calendar.MINUTE)
return h * 60 + m
}
private fun isNowLesson(
lessonDate: String,
lessonTime: String,
today: String,
nowMinutes: Int
): Boolean {
if (lessonDate != today) return false
val (s, e) = parseStartEnd(lessonTime) ?: return false
val start = toMinutes(s) ?: return false
val end = toMinutes(e) ?: return false
// обычно end > start; если вдруг кривые данные — не подсвечиваем
if (end <= start) return false
return nowMinutes in start..end
}
}

View File

@@ -1,51 +1,223 @@
package ru.fincode.tsudesk.feature.schedule.presentation.screen package ru.fincode.tsudesk.feature.schedule.presentation.screen
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import ru.fincode.tsudesk.core.common.log.logD import ru.fincode.tsudesk.core.common.log.logD
import ru.fincode.tsudesk.core.common.log.logE import ru.fincode.tsudesk.core.common.log.logE
import ru.fincode.tsudesk.core.common.model.DataResult import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
import ru.fincode.tsudesk.feature.schedule.domain.usecase.ObserveScheduleUseCase import ru.fincode.tsudesk.feature.schedule.domain.usecase.ObserveScheduleUseCase
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState import ru.fincode.tsudesk.feature.schedule.presentation.util.buildDayStrip
import ru.fincode.tsudesk.feature.schedule.presentation.util.toUiMessage
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class ScheduleViewModel @Inject constructor( class ScheduleViewModel @Inject constructor(
private val observeScheduleUseCase: ObserveScheduleUseCase private val observeScheduleUseCase: ObserveScheduleUseCase
) : ViewModel() { ) : ViewModel() {
private val scheduleType = ScheduleType.Group(number = "220631") private val _state = MutableStateFlow(
val state: StateFlow<ScheduleUiState> = observeScheduleUseCase(scheduleType).map { result ->
when (result) {
is DataResult.Data -> {
logD("${if (result.refreshedFromNetwork) "NETWORK" else "CACHE"}: ${result.data}")
ScheduleUiState( ScheduleUiState(
isLoading = false, selectedDate = todayString(),
data = result.data, dayStrip = buildDayStrip(todayString())
refreshedFromNetwork = result.refreshedFromNetwork
) )
)
val state: StateFlow<ScheduleUiState> = _state.asStateFlow()
init {
logD("ScheduleViewModel init. selectedDate=${_state.value.selectedDate}")
_state
.map { it.selectedType }
.distinctUntilChanged()
.onEach { type -> logD("Observe schedule: type=$type key=${type.toCacheKey()}") }
.flatMapLatest { type -> observeScheduleUseCase(type) }
.onEach { reduce(it) }
.onCompletion { cause ->
// страховка: если поток завершился и loading остался висеть
if (_state.value.isLoading) {
logD("Observe completed. cause=${cause?.message}. Stop loading.")
_state.update { it.copy(isLoading = false) }
}
}
.catch { e ->
logE("UseCase flow error", e)
_state.update {
it.copy(
isLoading = false,
error = e.message ?: "Ошибка"
)
}
}
.launchIn(viewModelScope)
}
fun onIntent(intent: ScheduleIntent) {
when (intent) {
is ScheduleIntent.ChangeGroupInput ->
_state.update { it.copy(groupInput = intent.value) }
is ScheduleIntent.SetGroupMenuExpanded ->
_state.update { it.copy(isGroupMenuExpanded = intent.expanded) }
is ScheduleIntent.SelectRecentGroup -> {
logD("SelectRecentGroup: ${intent.number}")
_state.update {
it.copy(
groupInput = intent.number,
isGroupMenuExpanded = false
)
}
}
is ScheduleIntent.RemoveRecentGroup -> {
logD("RemoveRecentGroup: ${intent.number}")
_state.update {
it.copy(recentGroups = it.recentGroups.filterNot { g -> g == intent.number })
}
}
ScheduleIntent.ApplyGroup -> {
logD("ApplyGroup clicked")
applyGroupAndReload()
}
is ScheduleIntent.SelectDate -> {
_state.update {
it.copy(
selectedDate = intent.date,
dayStrip = buildDayStrip(intent.date)
)
}
remapIfPossible()
}
is ScheduleIntent.SetViewMode -> {
_state.update { it.copy(viewMode = intent.mode) }
remapIfPossible()
}
ScheduleIntent.Retry -> {
// Ретрай можно сделать через "переприсвоение" selectedType (триггер distinctUntilChanged не пропустит)
// поэтому делаем принудительный рефреш: сбрасываем raw/error + ставим loading,
// а реальную перезагрузку должен инициировать usecase репозитория (если он умеет).
// Если в usecase нет принудительного refresh — оставляем как UI-retry (сброс ошибки).
logD("Retry requested")
_state.update { it.copy(error = null) }
// Если нужен именно network refresh — лучше добавить отдельный intent/usecase:
// observeScheduleUseCase.refresh(selectedType)
}
}
}
private fun applyGroupAndReload() {
val current = _state.value
if (current.isLoading) {
logD("ApplyGroup ignored: already loading")
return
}
val number = current.groupInput.trim()
if (number.isEmpty()) {
_state.update { it.copy(error = "Введите номер группы") }
return
}
val type = ScheduleType.Group(number)
logD("Start load schedule for group=$number key=${type.toCacheKey()}")
// При смене группы очищаем данные + обновляем selectedType.
// Наблюдение перезапустится автоматически (flatMapLatest по selectedType).
_state.update {
it.copy(
selectedType = type,
recentGroups = (listOf(number) + it.recentGroups).distinct().take(8),
isGroupMenuExpanded = false,
error = null,
raw = null,
isLoading = true, // визуально сразу показываем загрузку
dayLessons = emptyList(),
weekGrouped = emptyList()
)
}
}
private fun reduce(result: DataResult<ScheduleEntity>) {
when (result) {
DataResult.Loading -> {
logD("DataResult.Loading")
_state.update { it.copy(isLoading = true, error = null) }
}
is DataResult.Data -> {
val source = if (result.refreshedFromNetwork) "NETWORK" else "CACHE"
logD("DataResult.Data received. Source=$source lessons=${result.data.lessons.size}")
_state.update { it.copy(isLoading = false, error = null, raw = result) }
remapWithEntity(result.data)
} }
is DataResult.Error -> { is DataResult.Error -> {
logE("Error loading schedule: ${result.error}") logE("DataResult.Error received. error=${result.error}")
ScheduleUiState(isLoading = false, errorMessage = result.error.toString())
} val cached = result.data
} _state.update {
}.onStart { it.copy(
logD("Start collecting schedule flow") isLoading = false,
emit(ScheduleUiState(isLoading = true)) error = result.error.toUiMessage(),
}.stateIn( raw = result
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ScheduleUiState(isLoading = true)
) )
}
if (cached != null) {
logD("Using cached data. lessons=${cached.lessons.size}")
remapWithEntity(cached)
}
}
}
}
private fun remapIfPossible() {
val raw = _state.value.raw
val entity = when (raw) {
is DataResult.Data -> raw.data
is DataResult.Error -> raw.data
else -> null
} ?: return
logD("RemapIfPossible triggered")
remapWithEntity(entity)
}
private fun remapWithEntity(entity: ScheduleEntity) {
val selectedDate = _state.value.selectedDate
val strip = _state.value.dayStrip
val dayLessons = ScheduleUiMapper.mapDayLessons(entity, selectedDate)
val weekGrouped = ScheduleUiMapper.mapWeekGrouped(entity, strip)
logD("Remap complete: dayLessons=${dayLessons.size}, weekDays=${weekGrouped.size}")
_state.update {
it.copy(
dayLessons = dayLessons,
weekGrouped = weekGrouped
)
}
}
} }

View File

@@ -0,0 +1,11 @@
package ru.fincode.tsudesk.feature.schedule.presentation.util
import ru.fincode.tsudesk.core.common.model.AppError
fun AppError.toUiMessage(): String = when (this) {
AppError.NoInternet -> "Нет подключения к интернету"
AppError.Timeout -> "Превышено время ожидания"
AppError.Temporary -> "Не удалось обновить данные. Попробуйте ещё раз"
is AppError.Http -> "Ошибка сервера (HTTP ${this.code})"
is AppError.Unknown -> this.message ?: "Неизвестная ошибка"
}

View File

@@ -0,0 +1,103 @@
package ru.fincode.tsudesk.feature.schedule.presentation.util
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
private const val API_PATTERN = "dd.MM.yyyy"
/**
* Возвращает сегодняшнюю дату в формате dd.MM.yyyy
*/
fun todayString(): String {
val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
return sdf.format(Calendar.getInstance().time)
}
/**
* Строит список дат (Пн–Сб) для недели,
* к которой принадлежит anchor.
*/
fun buildDayStrip(anchor: String): List<String> {
val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
val cal = Calendar.getInstance()
val parsed = runCatching { sdf.parse(anchor) }.getOrNull()
?: return emptyList()
cal.time = parsed
// Сдвигаем к понедельнику
while (cal.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) {
cal.add(Calendar.DAY_OF_MONTH, -1)
}
return (0..5).map {
val out = sdf.format(cal.time)
cal.add(Calendar.DAY_OF_MONTH, 1)
out
}
}
/**
* Короткий день недели по строке dd.MM.yyyy
* Пример: "12.01.2026" -> "Пн"
*/
fun dowRuShort(date: String): String {
val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault())
val parsed = runCatching { sdf.parse(date) }.getOrNull()
?: return "Дн"
val cal = Calendar.getInstance().apply { time = parsed }
return when (cal.get(Calendar.DAY_OF_WEEK)) {
Calendar.MONDAY -> "Пн"
Calendar.TUESDAY -> "Вт"
Calendar.WEDNESDAY -> "Ср"
Calendar.THURSDAY -> "Чт"
Calendar.FRIDAY -> "Пт"
Calendar.SATURDAY -> "Сб"
Calendar.SUNDAY -> "Вс"
else -> "Дн"
}
}
/**
* Проверяет, является ли переданная дата сегодняшней.
*/
fun isToday(date: String): Boolean {
return date == todayString()
}
/**
* Преобразует строку времени "HH:mm" в минуты от начала суток.
* Возвращает null если формат некорректный.
*/
fun hmToMinutes(hm: String): Int? {
val parts = hm.split(":")
if (parts.size != 2) return null
val h = parts[0].toIntOrNull() ?: return null
val m = parts[1].toIntOrNull() ?: return null
if (h !in 0..23 || m !in 0..59) return null
return h * 60 + m
}
/**
* Преобразует диапазон времени "08:30 - 10:00"
* или "08:30 — 10:00" в пару минут (start, end).
*/
fun parseTimeRangeToMinutes(range: String): Pair<Int, Int>? {
val normalized = range
.replace("-", "")
.replace("", "")
val parts = normalized.split("").map { it.trim() }
if (parts.size != 2) return null
val start = hmToMinutes(parts[0]) ?: return null
val end = hmToMinutes(parts[1]) ?: return null
return start to end
}

View File

@@ -31,6 +31,7 @@ class SplashViewModel @Inject constructor(
val errorMessage = when (val result = getConfigUseCase()) { val errorMessage = when (val result = getConfigUseCase()) {
is DataResult.Data -> null is DataResult.Data -> null
is DataResult.Error -> result.error.toString() is DataResult.Error -> result.error.toString()
is DataResult.Loading -> null
} }
_state.update { _state.update {
it.copy( it.copy(

View File

@@ -8,10 +8,12 @@ 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"
serilization = "1.6.3" coroutines = "1.8.1"
serialization = "1.6.3"
kotlinxImmutable = "0.3.8" kotlinxImmutable = "0.3.8"
ksp = "1.9.24-1.0.20"
kotlinCompilerExtension = "1.5.14"
coreKtx = "1.10.1" coreKtx = "1.10.1"
appcompat = "1.6.1" appcompat = "1.6.1"
@@ -22,57 +24,64 @@ compose-bom = "2024.10.00"
hilt-nav-compose = "1.2.0" 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"
hilt = "2.50" hilt = "2.50"
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"
room = "2.6.1" room = "2.6.1"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
materialVersion = "1.13.0"
[libraries] [libraries]
# Android # KotlinX
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serilization" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
# UI: AndroidX, Jetpack Compose, etc kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
androidx-navigation-common = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigation" }
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" }
# Compose UI # AndroidX
compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-nav-compose" } androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "paging" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } # Compose (BOM)
compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-ui = { module = "androidx.compose.ui:ui" }
# Network: okhhtp3+retrofit2 compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-material3 = { module = "androidx.compose.material3:material3" }
# Navigation / Hilt
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-navigation-common = { module = "androidx.navigation:navigation-common-ktx", version.ref = "navigation" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-nav-compose" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
# 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" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
# Network-parse: Moshi+Gson retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
moshiKotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" } moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
retrofitMoshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
# DI: Hilt # Room
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
# DB: Room # Debug
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" }
[plugins] [plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }