From 97c909103814f410e179b35f51ed4a7125cc0926 Mon Sep 17 00:00:00 2001 From: Shcherbatykh Oleg Date: Tue, 24 Feb 2026 08:54:17 +0300 Subject: [PATCH] Start impl UI --- .../fincode/tsudesk/di/NetworkDebugModule.kt | 2 +- .../tsudesk/core/common/model/AppError.kt | 7 +- .../tsudesk/core/common/model/DataResult.kt | 12 +- .../tsudesk/core/network/NetworkCall.kt | 24 +- .../core/network/model/NetworkError.kt | 9 +- feature/schedule/build.gradle.kts | 1 + .../schedule/data/ScheduleRepositoryImpl.kt | 47 +- .../domain/usecase/ObserveScheduleUseCase.kt | 2 +- .../schedule/presentation/ScheduleAction.kt | 10 + .../schedule/presentation/ScheduleUiMapper.kt | 124 ++++ .../schedule/presentation/ScheduleUiState.kt | 11 + .../presentation/model/ScheduleUiState.kt | 11 - .../presentation/screen/ScheduleRoute.kt | 11 +- .../presentation/screen/ScheduleScreen.kt | 632 +++++++++++++++++- .../presentation/screen/ScheduleViewModel.kt | 86 ++- .../presentation/util/ErrorUiMapper.kt | 11 + .../presentation/util/ScheduleDateUtils.kt | 103 +++ gradle/libs.versions.toml | 2 + 18 files changed, 1025 insertions(+), 80 deletions(-) create mode 100644 feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleAction.kt create mode 100644 feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiMapper.kt create mode 100644 feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiState.kt delete mode 100644 feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/model/ScheduleUiState.kt create mode 100644 feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ErrorUiMapper.kt create mode 100644 feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ScheduleDateUtils.kt diff --git a/app/src/main/java/ru/fincode/tsudesk/di/NetworkDebugModule.kt b/app/src/main/java/ru/fincode/tsudesk/di/NetworkDebugModule.kt index 1372c11..d851a4d 100644 --- a/app/src/main/java/ru/fincode/tsudesk/di/NetworkDebugModule.kt +++ b/app/src/main/java/ru/fincode/tsudesk/di/NetworkDebugModule.kt @@ -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, diff --git a/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/AppError.kt b/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/AppError.kt index 69f41ca..7013d23 100644 --- a/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/AppError.kt +++ b/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/AppError.kt @@ -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 -} +} \ No newline at end of file diff --git a/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/DataResult.kt b/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/DataResult.kt index ce7da49..3babea9 100644 --- a/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/DataResult.kt +++ b/core/common/src/main/java/ru/fincode/tsudesk/core/common/model/DataResult.kt @@ -3,10 +3,16 @@ package ru.fincode.tsudesk.core.common.model sealed interface DataResult { data class Data( - val data: T, val refreshedFromNetwork: Boolean + val data: T, + val refreshedFromNetwork: Boolean ) : DataResult data class Error( - val error: AppError, val data: T? = null, val cause: Throwable? = null + val error: AppError, + val data: T? = null, + val cause: Throwable? = null ) : DataResult -} + + /** Состояние загрузки */ + data object Loading : DataResult +} \ No newline at end of file diff --git a/core/network/src/main/java/ru/fincode/tsudesk/core/network/NetworkCall.kt b/core/network/src/main/java/ru/fincode/tsudesk/core/network/NetworkCall.kt index fe14753..93da39b 100644 --- a/core/network/src/main/java/ru/fincode/tsudesk/core/network/NetworkCall.kt +++ b/core/network/src/main/java/ru/fincode/tsudesk/core/network/NetworkCall.kt @@ -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 apiCall( crossinline block: suspend () -> Response @@ -51,8 +56,23 @@ suspend inline fun 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) } diff --git a/core/network/src/main/java/ru/fincode/tsudesk/core/network/model/NetworkError.kt b/core/network/src/main/java/ru/fincode/tsudesk/core/network/model/NetworkError.kt index 8994822..4e62336 100644 --- a/core/network/src/main/java/ru/fincode/tsudesk/core/network/model/NetworkError.kt +++ b/core/network/src/main/java/ru/fincode/tsudesk/core/network/model/NetworkError.kt @@ -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,7 +26,8 @@ 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) } -} +} \ No newline at end of file diff --git a/feature/schedule/build.gradle.kts b/feature/schedule/build.gradle.kts index 8635a70..eeaf794 100644 --- a/feature/schedule/build.gradle.kts +++ b/feature/schedule/build.gradle.kts @@ -43,6 +43,7 @@ kapt { dependencies { implementation(libs.androidx.appcompat) implementation(libs.material) + implementation(libs.kotlinx.collections.immutable) // Compose implementation(platform(libs.compose.bom)) diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/data/ScheduleRepositoryImpl.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/data/ScheduleRepositoryImpl.kt index 4f042eb..3b1d69c 100644 --- a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/data/ScheduleRepositoryImpl.kt +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/data/ScheduleRepositoryImpl.kt @@ -1,9 +1,11 @@ package ru.fincode.tsudesk.feature.schedule.data +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import ru.fincode.tsudesk.core.common.model.DataResult +import ru.fincode.tsudesk.core.network.model.NetworkError import ru.fincode.tsudesk.core.network.model.NetworkResult import ru.fincode.tsudesk.core.network.model.map import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleLocalDataSource @@ -29,8 +31,14 @@ class ScheduleRepositoryImpl @Inject constructor( if ((System.currentTimeMillis() - cached.timestamp) < CACHE_TTL_MS) return@flow } - val networkResult = loadSchedule(type) + emit(DataResult.Loading) + + val networkResult = loadScheduleWithRetry(type).map(mapper::invoke) + if (networkResult is NetworkResult.Error) { + // Если кеш уже был показан — не роняем UI ошибкой + if (cached != null) return@flow + emit(DataResult.Error(networkResult.error.toAppError())) return@flow } @@ -44,14 +52,37 @@ class ScheduleRepositoryImpl @Inject constructor( } } - private suspend fun loadSchedule(type: ScheduleType): NetworkResult = - when (type) { - is ScheduleType.Group -> remote.loadScheduleByGroup(type.number).map(mapper::invoke) + private suspend fun loadScheduleWithRetry( + type: ScheduleType + ): NetworkResult { - is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name).map(mapper::invoke) + var last: NetworkResult + + repeat(3) { attempt -> + last = loadScheduleDto(type) + if (last is NetworkResult.Success) return last + + val err = (last as NetworkResult.Error).error + if (!err.isRetryable() || attempt == 2) return last + + delay(300L * (attempt + 1)) // 300, 600 } - private companion object { - private const val CACHE_TTL_MS: Long = 1;//24L * 60L * 60L * 1000L // 1 day + return loadScheduleDto(type) } -} + + private suspend fun loadScheduleDto( + type: ScheduleType + ): NetworkResult = + when (type) { + is ScheduleType.Group -> remote.loadScheduleByGroup(type.number) + is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name) + } + + private fun NetworkError.isRetryable(): Boolean = + this is NetworkError.Temporary || this is NetworkError.Timeout + + private companion object { + private const val CACHE_TTL_MS: Long = 1 // оставил как у тебя + } +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/domain/usecase/ObserveScheduleUseCase.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/domain/usecase/ObserveScheduleUseCase.kt index 83ae425..2e1304f 100644 --- a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/domain/usecase/ObserveScheduleUseCase.kt +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/domain/usecase/ObserveScheduleUseCase.kt @@ -12,4 +12,4 @@ class ObserveScheduleUseCase @Inject constructor( ) { operator fun invoke(type: ScheduleType): Flow> = repository.observeSchedule(type) -} +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleAction.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleAction.kt new file mode 100644 index 0000000..82211e0 --- /dev/null +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleAction.kt @@ -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 +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiMapper.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiMapper.kt new file mode 100644 index 0000000..a7fe8ee --- /dev/null +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiMapper.kt @@ -0,0 +1,124 @@ +package ru.fincode.tsudesk.feature.schedule.presentation + +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 java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.UUID + +object ScheduleUiMapper { + + private const val API_PATTERN = "dd.MM.yyyy" + private val sdf = SimpleDateFormat(API_PATTERN, Locale.getDefault()) + + fun mapDayLessons(entity: ScheduleEntity, selectedDate: String): List { + val now = Calendar.getInstance() + val nowDate = sdf.format(now.time) + + return entity.lessons + .asSequence() + .filter { it.date == selectedDate } + .sortedBy { parseStartMinutes(it.time) ?: Int.MAX_VALUE } + .map { it.toUi(isNow = isNowLesson(it, nowDate, now)) } + .toList() + } + + fun mapWeekGrouped(entity: ScheduleEntity, weekDates: List): List { + val now = Calendar.getInstance() + val nowDate = sdf.format(now.time) + + return weekDates.map { date -> + val lessons = entity.lessons + .asSequence() + .filter { it.date == date } + .sortedBy { parseStartMinutes(it.time) ?: Int.MAX_VALUE } + .map { it.toUi(isNow = isNowLesson(it, nowDate, now)) } + .toList() + + UiWeekDay( + date = date, + title = "${dowRu(date)}, $date", + lessons = lessons + ) + } + } + + private fun LessonEntity.toUi(isNow: Boolean): UiLesson { + val (badgeText, badgeKind) = when (type) { + LessonType.LECTURE -> "Лекция" to BadgeKind.INFO + LessonType.PRACTICE -> "Практика" to BadgeKind.PRIMARY + LessonType.LAB -> "Лабораторная" to BadgeKind.LAB + LessonType.DEFAULT -> (typeName.ifBlank { "Занятие" }) to BadgeKind.MUTED + } + + return UiLesson( + id = stableId(this), + time = normalizeDash(time), + title = name, + teacher = teacher, + room = room, + badgeText = badgeText, + badgeKind = badgeKind, + isNow = isNow + ) + } + + private fun isNowLesson(lesson: LessonEntity, nowDate: String, now: Calendar): Boolean { + if (lesson.date != nowDate) return false + val (startMin, endMin) = parseRangeMinutes(lesson.time) ?: return false + val curMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + return curMin in startMin..endMin + } + + private fun parseRangeMinutes(time: String): Pair? { + val cleaned = normalizeDash(time) + val parts = cleaned.split("—").map { it.trim() } + if (parts.size != 2) return null + val s = parseHmToMinutes(parts[0]) ?: return null + val e = parseHmToMinutes(parts[1]) ?: return null + return s to e + } + + private fun parseStartMinutes(time: String): Int? { + val cleaned = normalizeDash(time) + val parts = cleaned.split("—").map { it.trim() } + if (parts.isEmpty()) return null + return parseHmToMinutes(parts[0]) + } + + private fun parseHmToMinutes(hm: String): Int? { + // "08:30" + val p = hm.split(":") + if (p.size != 2) return null + val h = p[0].toIntOrNull() ?: return null + val m = p[1].toIntOrNull() ?: return null + if (h !in 0..23 || m !in 0..59) return null + return h * 60 + m + } + + private fun normalizeDash(s: String): String = + s.replace("-", "—").replace("–", "—") + + private fun stableId(l: LessonEntity): String { + val key = "${l.date}|${l.time}|${l.name}|${l.teacher}|${l.room}|${l.type}" + return UUID.nameUUIDFromBytes(key.toByteArray()).toString() + } + + private fun dowRu(date: String): String { + // вычислим день недели через Calendar + 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 -> "Дн" + } + } +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiState.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiState.kt new file mode 100644 index 0000000..6e56a2b --- /dev/null +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/ScheduleUiState.kt @@ -0,0 +1,11 @@ +package ru.fincode.tsudesk.feature.schedule.presentation + +import ru.fincode.tsudesk.core.common.model.AppError +import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleEntity + +data class ScheduleUiState( + val selectedGroup: String = "", + val isLoading: Boolean = false, + val schedule: ScheduleEntity? = null, + val error: AppError? = null +) \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/model/ScheduleUiState.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/model/ScheduleUiState.kt deleted file mode 100644 index 34c1461..0000000 --- a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/model/ScheduleUiState.kt +++ /dev/null @@ -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 -) \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleRoute.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleRoute.kt index 18b524e..9270b99 100644 --- a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleRoute.kt +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleRoute.kt @@ -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 + ) +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleScreen.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleScreen.kt index 1ec5e4d..4fae954 100644 --- a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleScreen.kt +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleScreen.kt @@ -1,27 +1,629 @@ -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.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)) }, + + // ✅ ИЗМЕНЕНИЕ #1: выбор из dropdown сразу запускает загрузку + 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, + 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) { + Box { + OutlinedTextField( + value = groupInput, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = true, + label = { Text("Группа") }, + + // ✅ ИЗМЕНЕНИЕ #2: Done/Enter сразу запускает ApplyGroup + 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 = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = num, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + 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.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" +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleViewModel.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleViewModel.kt index eabcea7..4df3167 100644 --- a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleViewModel.kt +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/screen/ScheduleViewModel.kt @@ -1,51 +1,73 @@ -package ru.fincode.tsudesk.feature.schedule.presentation.screen +package ru.fincode.tsudesk.feature.schedule.presentation -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.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import ru.fincode.tsudesk.core.common.log.logD -import ru.fincode.tsudesk.core.common.log.logE +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import ru.fincode.tsudesk.core.common.model.DataResult 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.domain.repository.ScheduleRepository import javax.inject.Inject @HiltViewModel class ScheduleViewModel @Inject constructor( - private val observeScheduleUseCase: ObserveScheduleUseCase + private val repository: ScheduleRepository ) : ViewModel() { - private val scheduleType = ScheduleType.Group(number = "220631") + private val _uiState = MutableStateFlow(ScheduleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - val state: StateFlow = observeScheduleUseCase(scheduleType).map { result -> - when (result) { - is DataResult.Data -> { - logD("${if (result.refreshedFromNetwork) "NETWORK" else "CACHE"}: ${result.data}") - ScheduleUiState( - isLoading = false, - data = result.data, - refreshedFromNetwork = result.refreshedFromNetwork + private var loadJob: Job? = null + + fun onAction(action: ScheduleAction) { + when (action) { + is ScheduleAction.GroupSelected -> { + _uiState.value = _uiState.value.copy( + selectedGroup = action.group, + error = null ) + startLoad(action.group) // ✅ автозагрузка } - is DataResult.Error -> { - logE("Error loading schedule: ${result.error}") - ScheduleUiState(isLoading = false, errorMessage = result.error.toString()) + ScheduleAction.ApplyGroupClicked -> { + val group = _uiState.value.selectedGroup + if (group.isNotBlank()) startLoad(group) } } - }.onStart { - logD("Start collecting schedule flow") - emit(ScheduleUiState(isLoading = true)) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = ScheduleUiState(isLoading = true) - ) -} + } + + private fun startLoad(group: String) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + repository.observeSchedule(ScheduleType.Group(group)).collect { result -> + when (result) { + is DataResult.Loading -> { + _uiState.value = _uiState.value.copy( + isLoading = true, + error = null + ) + } + + is DataResult.Data -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + schedule = result.data, + error = null + ) + } + + is DataResult.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = result.error + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ErrorUiMapper.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ErrorUiMapper.kt new file mode 100644 index 0000000..09b20bb --- /dev/null +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ErrorUiMapper.kt @@ -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 ?: "Неизвестная ошибка" +} \ No newline at end of file diff --git a/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ScheduleDateUtils.kt b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ScheduleDateUtils.kt new file mode 100644 index 0000000..2e23e2f --- /dev/null +++ b/feature/schedule/src/main/java/ru/fincode/tsudesk/feature/schedule/presentation/util/ScheduleDateUtils.kt @@ -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 { + 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? { + 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 +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f67405..212b09b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ kotlin = "1.9.24" jvmTarget = "17" kotlinCompilerExtension = "1.5.14" serilization = "1.6.3" +kotlinxImmutable = "0.3.8" coreKtx = "1.10.1" appcompat = "1.6.1" @@ -40,6 +41,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- 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" } +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" }