Start impl UI
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,16 @@ 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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ kapt {
|
||||
dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
|
||||
@@ -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<ScheduleEntity> =
|
||||
when (type) {
|
||||
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number).map(mapper::invoke)
|
||||
private suspend fun loadScheduleWithRetry(
|
||||
type: ScheduleType
|
||||
): NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> {
|
||||
|
||||
is ScheduleType.Teacher -> remote.loadScheduleByTeacher(type.name).map(mapper::invoke)
|
||||
var last: NetworkResult<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto>
|
||||
|
||||
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<ru.fincode.tsudesk.feature.schedule.data.remote.dto.ScheduleDto> =
|
||||
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 // оставил как у тебя
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,4 @@ class ObserveScheduleUseCase @Inject constructor(
|
||||
) {
|
||||
operator fun invoke(type: ScheduleType): Flow<DataResult<ScheduleEntity>> =
|
||||
repository.observeSchedule(type)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<UiLesson> {
|
||||
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<String>): List<UiWeekDay> {
|
||||
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<Int, Int>? {
|
||||
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 -> "Дн"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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<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) {
|
||||
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"
|
||||
}
|
||||
@@ -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<ScheduleUiState> = _uiState.asStateFlow()
|
||||
|
||||
val state: StateFlow<ScheduleUiState> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ?: "Неизвестная ошибка"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user