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

# Conflicts:
#	gradle/libs.versions.toml
This commit is contained in:
Shcherbatykh Oleg
2026-02-24 15:38:48 +03:00
27 changed files with 1413 additions and 179 deletions

View File

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

View File

@@ -18,7 +18,7 @@ object AppConfigModule {
@Provides
@Singleton
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(
isDebug = BuildConfig.DEBUG,
baseUrl = baseUrl,

View File

@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<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>
<style name="Theme.TSUDesk" parent="android:Theme.Material.NoActionBar" />
</resources>

View File

@@ -1,16 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<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>
<style name="Theme.TSUDesk" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -1,7 +1,12 @@
package ru.fincode.tsudesk.core.common.model
sealed interface AppError {
data object NoInternet : AppError
data object Timeout : AppError
/** Временный сбой соединения (EOF / unexpected end of stream / reset) */
data object Temporary : AppError
data class Http(val code: Int) : 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> {
data class Data<T>(
val data: T, val refreshedFromNetwork: Boolean
val data: T,
val refreshedFromNetwork: Boolean
) : DataResult<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>
data object Loading : DataResult<Nothing>
}

View File

@@ -45,11 +45,11 @@ dependencies {
api(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.converter.gson)
implementation(libs.retrofit.gson)
api(libs.moshi)
api(libs.moshiKotlin)
api(libs.retrofitMoshi)
api(libs.moshi.kotlin)
api(libs.retrofit.moshi)
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.NetworkResult
import ru.fincode.tsudesk.core.network.model.NetworkResult.Success
import java.io.EOFException
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
suspend inline fun <T> apiCall(
crossinline block: suspend () -> Response<T>
@@ -51,8 +56,23 @@ suspend inline fun <T> apiCall(
}
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 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)
}

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 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(
val code: Int,
override val meta: NetworkMeta
@@ -20,6 +26,7 @@ sealed class NetworkError(open val meta: NetworkMeta) {
fun toAppError(): AppError = when (this) {
is NoInternet -> AppError.NoInternet
is Timeout -> AppError.Timeout
is Temporary -> AppError.Temporary
is Http -> AppError.Http(code)
is Unknown -> AppError.Unknown(throwable.message)
}

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,8 @@ android {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
@@ -29,40 +30,45 @@ android {
sourceCompatibility = jvm
targetCompatibility = jvm
}
kotlinOptions {
jvmTarget = jvm.toString()
}
kotlinOptions { jvmTarget = jvm.toString() }
buildFeatures {
buildConfig = 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(libs.compose.runtime)
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
// Navigation Compose
// Navigation (Compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.common)
kapt(libs.hilt.compiler)
// DI
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(projects.core.network)
implementation(projects.core.database)
// Core modules
implementation(projects.core.common)
implementation(projects.core.config)
implementation(projects.core.ui)
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 {
val scheduleKey = type.toCacheKey()
val cached = local.observeSchedule(scheduleKey).first()
if (cached != null) {
emit(DataResult.Data(cached, refreshedFromNetwork = false))
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 (cached != null) return@flow
emit(DataResult.Error(networkResult.error.toAppError()))
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) {
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number).map(mapper::invoke)
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name).map(mapper::invoke)
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number)
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name)
}
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleViewModel
import ru.fincode.tsudesk.feature.schedule.presentation.ui.ScheduleScreen
@Composable
fun ScheduleRoute(
viewModel: ScheduleViewModel = hiltViewModel()
) {
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.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState
import androidx.compose.ui.draw.clip
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
fun ScheduleScreen(
state: ScheduleUiState
state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
Column(modifier.fillMaxSize()) {
ScheduleHeader(state, onIntent)
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) {
CircularProgressIndicator()
} else {
Text(text = state.title, color = Color.Black)
Row(verticalAlignment = Alignment.CenterVertically) {
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 {
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,20 +1,28 @@
package ru.fincode.tsudesk.feature.schedule.presentation.screen
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.MutableStateFlow
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.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.onCompletion
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.logE
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.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
@HiltViewModel
@@ -22,30 +30,192 @@ class ScheduleViewModel @Inject constructor(
private val observeScheduleUseCase: ObserveScheduleUseCase
) : ViewModel() {
private val scheduleType = ScheduleType.Group(number = "220631")
private val _state = MutableStateFlow(
ScheduleUiState(
selectedDate = todayString(),
dayStrip = buildDayStrip(todayString())
)
)
val state: StateFlow<ScheduleUiState> = _state.asStateFlow()
val state: StateFlow<ScheduleUiState> = observeScheduleUseCase(scheduleType).map { result ->
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 -> {
logD("${if (result.refreshedFromNetwork) "NETWORK" else "CACHE"}: ${result.data}")
ScheduleUiState(
isLoading = false,
data = result.data,
refreshedFromNetwork = result.refreshedFromNetwork
)
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 -> {
logE("Error loading schedule: ${result.error}")
ScheduleUiState(isLoading = false, errorMessage = result.error.toString())
logE("DataResult.Error received. error=${result.error}")
val cached = result.data
_state.update {
it.copy(
isLoading = false,
error = result.error.toUiMessage(),
raw = result
)
}
if (cached != null) {
logD("Using cached data. lessons=${cached.lessons.size}")
remapWithEntity(cached)
}
}
}
}.onStart {
logD("Start collecting schedule flow")
emit(ScheduleUiState(isLoading = true))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ScheduleUiState(isLoading = true)
)
}
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()) {
is DataResult.Data -> null
is DataResult.Error -> result.error.toString()
is DataResult.Loading -> null
}
_state.update {
it.copy(

View File

@@ -8,10 +8,12 @@ versionCode = "1"
agp = "8.12.0"
kotlin = "1.9.24"
jvmTarget = "17"
kotlinCompilerExtension = "1.5.14"
serilization = "1.6.3"
coroutines = "1.8.1"
serialization = "1.6.3"
kotlinxImmutable = "0.3.8"
ksp = "1.9.24-1.0.20"
kotlinCompilerExtension = "1.5.14"
coreKtx = "1.10.1"
appcompat = "1.6.1"
@@ -21,58 +23,61 @@ constraintlayout = "2.1.4"
compose-bom = "2024.10.00"
hilt-nav-compose = "1.2.0"
navigation = "2.8.5"
paging = "3.3.2"
lifecycle = "2.7.0"
hilt = "2.50"
retrofit = "2.11.0"
okhttp = "4.12.0"
moshi = "1.15.1"
room = "2.6.1"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
materialVersion = "1.13.0"
[libraries]
# Android
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serilization" }
# UI: AndroidX, Jetpack Compose, etc
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
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", 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-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
# Compose UI
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-nav-compose" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
# Network: okhhtp3+retrofit2
# AndroidX
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
# Compose (BOM)
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-material3 = { module = "androidx.compose.material3:material3" }
# Navigation / Hilt
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-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
# Network-parse: Moshi+Gson
moshi = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" }
moshiKotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshi" }
retrofitMoshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
# DI: Hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
# DB: Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
# Room
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
# Debug
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" }
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
android-application = { id = "com.android.application", 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-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" }