Start impl UI

This commit is contained in:
2026-02-24 08:54:17 +03:00
parent b440222575
commit 97c9091038
18 changed files with 1025 additions and 80 deletions

View File

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

View File

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

View File

@@ -3,10 +3,16 @@ package ru.fincode.tsudesk.core.common.model
sealed interface DataResult<out T> { sealed interface DataResult<out T> {
data class Data<T>( data class Data<T>(
val data: T, val refreshedFromNetwork: Boolean val data: T,
val refreshedFromNetwork: Boolean
) : DataResult<T> ) : DataResult<T>
data class Error<T>( data class Error<T>(
val error: AppError, val data: T? = null, val cause: Throwable? = null val error: AppError,
val data: T? = null,
val cause: Throwable? = null
) : DataResult<T> ) : DataResult<T>
/** Состояние загрузки */
data object Loading : DataResult<Nothing>
} }

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ kapt {
dependencies { dependencies {
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) implementation(libs.material)
implementation(libs.kotlinx.collections.immutable)
// Compose // Compose
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))

View File

@@ -1,9 +1,11 @@
package ru.fincode.tsudesk.feature.schedule.data package ru.fincode.tsudesk.feature.schedule.data
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import ru.fincode.tsudesk.core.common.model.DataResult 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.NetworkResult
import ru.fincode.tsudesk.core.network.model.map import ru.fincode.tsudesk.core.network.model.map
import ru.fincode.tsudesk.feature.schedule.data.datasource.ScheduleLocalDataSource 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 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) { if (networkResult is NetworkResult.Error) {
// Если кеш уже был показан — не роняем UI ошибкой
if (cached != null) return@flow
emit(DataResult.Error(networkResult.error.toAppError())) emit(DataResult.Error(networkResult.error.toAppError()))
return@flow return@flow
} }
@@ -44,14 +52,37 @@ class ScheduleRepositoryImpl @Inject constructor(
} }
} }
private suspend fun loadSchedule(type: ScheduleType): NetworkResult<ScheduleEntity> = private suspend fun loadScheduleWithRetry(
when (type) { type: ScheduleType
is ScheduleType.Group -> remote.loadScheduleByGroup(type.number).map(mapper::invoke) ): 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
} }
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 companion object {
private const val CACHE_TTL_MS: Long = 1;//24L * 60L * 60L * 1000L // 1 day private const val CACHE_TTL_MS: Long = 1 // оставил как у тебя
} }
} }

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

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

View File

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

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

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

View File

@@ -1,27 +1,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.background
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.border
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.foundation.horizontalScroll
import androidx.compose.material3.Text import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.fincode.tsudesk.core.common.model.DataResult
import ru.fincode.tsudesk.feature.schedule.presentation.*
import ru.fincode.tsudesk.feature.schedule.presentation.util.dowRuShort
import ru.fincode.tsudesk.feature.schedule.presentation.util.todayString
@Composable @Composable
fun ScheduleScreen( fun ScheduleScreen(
state: ScheduleUiState state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit,
modifier: Modifier = Modifier
) { ) {
Box( Column(modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), ScheduleHeader(state, onIntent)
contentAlignment = Alignment.Center
when (state.viewMode) {
ScheduleViewMode.DAY -> DayPicker(state, onIntent)
ScheduleViewMode.WEEK -> WeekHeaderStub(state)
}
ScheduleContent(
state = state,
onIntent = onIntent,
modifier = Modifier.weight(1f)
)
}
}
@Composable
private fun ScheduleHeader(
state: ScheduleUiState,
onIntent: (ScheduleIntent) -> Unit
) {
val shape = RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)
val headerBrush = Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.error,
MaterialTheme.colorScheme.error.copy(alpha = 0.75f)
)
)
Column(
Modifier
.fillMaxWidth()
.clip(shape)
.background(headerBrush)
.padding(top = 20.dp, start = 16.dp, end = 16.dp, bottom = 14.dp)
) { ) {
if (state.isLoading) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator() Column(Modifier.weight(1f)) {
Text(
text = "Расписание",
color = MaterialTheme.colorScheme.onError,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "ТулГУ • Осенний семестр",
color = MaterialTheme.colorScheme.onError.copy(alpha = 0.8f),
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
ModeToggle(
mode = state.viewMode,
onMode = { onIntent(ScheduleIntent.SetViewMode(it)) }
)
}
Spacer(Modifier.height(12.dp))
GroupSelector(
groupInput = state.groupInput,
recentGroups = state.recentGroups,
expanded = state.isGroupMenuExpanded,
isLoading = state.isLoading,
onExpanded = { onIntent(ScheduleIntent.SetGroupMenuExpanded(it)) },
onValueChange = { onIntent(ScheduleIntent.ChangeGroupInput(it)) },
// ✅ ИЗМЕНЕНИЕ #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 { } else {
Text(text = state.title, color = Color.Black) 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"
}

View File

@@ -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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.stateIn
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.core.common.model.DataResult
import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType import ru.fincode.tsudesk.feature.schedule.domain.model.ScheduleType
import ru.fincode.tsudesk.feature.schedule.domain.usecase.ObserveScheduleUseCase import ru.fincode.tsudesk.feature.schedule.domain.repository.ScheduleRepository
import ru.fincode.tsudesk.feature.schedule.presentation.model.ScheduleUiState
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ScheduleViewModel @Inject constructor( class ScheduleViewModel @Inject constructor(
private val observeScheduleUseCase: ObserveScheduleUseCase private val repository: ScheduleRepository
) : ViewModel() { ) : 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 -> 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) // ✅ автозагрузка
}
ScheduleAction.ApplyGroupClicked -> {
val group = _uiState.value.selectedGroup
if (group.isNotBlank()) startLoad(group)
}
}
}
private fun startLoad(group: String) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
repository.observeSchedule(ScheduleType.Group(group)).collect { result ->
when (result) { when (result) {
is DataResult.Loading -> {
_uiState.value = _uiState.value.copy(
isLoading = true,
error = null
)
}
is DataResult.Data -> { is DataResult.Data -> {
logD("${if (result.refreshedFromNetwork) "NETWORK" else "CACHE"}: ${result.data}") _uiState.value = _uiState.value.copy(
ScheduleUiState(
isLoading = false, isLoading = false,
data = result.data, schedule = result.data,
refreshedFromNetwork = result.refreshedFromNetwork error = null
) )
} }
is DataResult.Error -> { is DataResult.Error -> {
logE("Error loading schedule: ${result.error}") _uiState.value = _uiState.value.copy(
ScheduleUiState(isLoading = false, errorMessage = result.error.toString()) isLoading = false,
} error = result.error
}
}.onStart {
logD("Start collecting schedule flow")
emit(ScheduleUiState(isLoading = true))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ScheduleUiState(isLoading = true)
) )
}
}
}
}
}
} }

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

@@ -10,6 +10,7 @@ kotlin = "1.9.24"
jvmTarget = "17" jvmTarget = "17"
kotlinCompilerExtension = "1.5.14" kotlinCompilerExtension = "1.5.14"
serilization = "1.6.3" serilization = "1.6.3"
kotlinxImmutable = "0.3.8"
coreKtx = "1.10.1" coreKtx = "1.10.1"
appcompat = "1.6.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" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 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
compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }