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
@Singleton
fun provideAppConfig(): AppConfig {
val baseUrl = if (BuildConfig.DEBUG) BASE_URL_DEVELOP else BASE_URL_PROD
val baseUrl = if (BuildConfig.DEBUG) BASE_URL_PROD else BASE_URL_PROD
return AppConfig(
isDebug = BuildConfig.DEBUG,
baseUrl = baseUrl,

View File

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

View File

@@ -3,10 +3,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>
}

View File

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

View File

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

View File

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

View File

@@ -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
}
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;//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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.fincode.tsudesk.feature.schedule.presentation.screen.ScheduleViewModel
import ru.fincode.tsudesk.feature.schedule.presentation.ui.ScheduleScreen
@Composable
fun ScheduleRoute(
viewModel: ScheduleViewModel = hiltViewModel()
) {
val state = viewModel.state.collectAsStateWithLifecycle().value
ScheduleScreen(state = state)
ScheduleScreen(
state = state,
onIntent = viewModel::onIntent
)
}

View File

@@ -1,27 +1,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
) {
if (state.isLoading) {
CircularProgressIndicator()
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)
) {
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 {
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.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 ->
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) {
is DataResult.Loading -> {
_uiState.value = _uiState.value.copy(
isLoading = true,
error = null
)
}
is DataResult.Data -> {
logD("${if (result.refreshedFromNetwork) "NETWORK" else "CACHE"}: ${result.data}")
ScheduleUiState(
_uiState.value = _uiState.value.copy(
isLoading = false,
data = result.data,
refreshedFromNetwork = result.refreshedFromNetwork
schedule = result.data,
error = null
)
}
is DataResult.Error -> {
logE("Error loading schedule: ${result.error}")
ScheduleUiState(isLoading = false, errorMessage = result.error.toString())
}
}
}.onStart {
logD("Start collecting schedule flow")
emit(ScheduleUiState(isLoading = true))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ScheduleUiState(isLoading = true)
_uiState.value = _uiState.value.copy(
isLoading = false,
error = result.error
)
}
}
}
}
}
}

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"
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" }