feat: Enhance session handling in timetable and UI

- Introduced session types (exams, credits, consultations, coursework defenses) with distinct icons and colors for better visibility in the UI.
- Updated PlansScreen, ScheduleScreen, and WeekGrid to display session information appropriately.
- Modified CalendarView and PlansView to include session indicators in the calendar and plans.
- Enhanced LessonCard and CompactLesson to show session details and adjust styling based on session presence.
- Added new SessionMark component for consistent session representation across Android and iOS.
- Updated server logic to handle session entries from external documents, ensuring accurate timetable representation.
- Improved date parsing to accommodate session-related dates in various formats.
- Added tests to verify session handling and date parsing functionality.
This commit is contained in:
2026-10-19 20:49:13 +03:00
parent db9a3f8243
commit 15851b26cd
21 changed files with 421 additions and 42 deletions

View File

@@ -242,6 +242,7 @@ fun CalendarScreen(vm: MainViewModel, nav: AppNav) {
selected = selected,
hasLessons = { d -> t != null && vm.slotsOn(t, d, subgroup).isNotEmpty() },
hasPlans = { d -> notes.any { it.date == d.toString() && (!it.task || !it.done) } },
hasSession = { d -> t != null && vm.slotsOn(t, d, subgroup).any { s -> s.lessons.any { it.session != null } } },
weekOf = { w -> t?.days?.firstOrNull { it.date == w.toString() }?.week },
onSelect = { selected = it },
)
@@ -348,6 +349,7 @@ private fun MonthGrid(
selected: LocalDate,
hasLessons: (LocalDate) -> Boolean,
hasPlans: (LocalDate) -> Boolean,
hasSession: (LocalDate) -> Boolean,
weekOf: (LocalDate) -> Week?,
onSelect: (LocalDate) -> Unit,
) {
@@ -372,6 +374,7 @@ private fun MonthGrid(
val isToday = d == today()
val lessons = inMonth && hasLessons(d)
val plans = inMonth && hasPlans(d)
val session = inMonth && hasSession(d)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -388,7 +391,7 @@ private fun MonthGrid(
.semantics(mergeDescendants = true) {
contentDescription = "${d.weekdayName()}, ${d.longLabel()}" +
(if (isToday) ", сегодня" else "") + (if (lessons) ", есть пары" else "") +
(if (plans) ", есть планы" else "")
(if (plans) ", есть планы" else "") + (if (session) ", экзамен или зачёт" else "")
},
) {
Box(
@@ -408,9 +411,10 @@ private fun MonthGrid(
},
)
}
// Two small marks: lessons in the calm tone, plans in the accent.
// Small marks: lessons in the calm tone, an exam or credit in amber, plans in the accent.
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.height(8.dp).padding(top = 2.dp)) {
if (lessons) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape))
if (session) Box(Modifier.size(6.dp).background(sessionColors().accent, MaterialTheme.shapes.extraSmall))
else if (lessons) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape))
if (plans) Box(Modifier.size(5.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape))
}
}
@@ -517,13 +521,18 @@ private fun DayAgendaContent(
lessons.forEachIndexed { i, (slot, l) ->
item(key = "${slot.start}|${l.key}") {
val color = subjectColor(l.subject)
val session = l.session
TimelineItem(
marker = { Box(Modifier.size(20.dp).background(color.container, CircleShape)) },
over = "${slot.start} → ${slot.end}",
marker = {
if (session != null) SessionDot(session, 24.dp)
else Box(Modifier.size(20.dp).background(color.container, CircleShape))
},
over = listOfNotNull(session?.label, "${slot.start} → ${slot.end}").joinToString(" · "),
overColor = if (session != null) sessionColors().accent else null,
title = l.subject,
under = listOfNotNull(
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
l.type.ifEmpty { null }?.lowercase(),
l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(),
).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex,
onClick = { onLesson(l) },
@@ -577,6 +586,7 @@ private fun TimelineItem(
title: String,
under: String? = null,
done: Boolean = false,
overColor: Color? = null,
last: Boolean,
onClick: () -> Unit,
) {
@@ -610,7 +620,7 @@ private fun TimelineItem(
}
Column(Modifier.weight(1f).padding(start = 16.dp, top = 14.dp, bottom = 10.dp)) {
over?.let {
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
TimeText(it, style = MaterialTheme.typography.bodyMedium, color = overColor ?: MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
Text(
title,

View File

@@ -505,6 +505,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
SubjectDot(lesson.subject, 14.dp, Modifier.padding(top = 8.dp))
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
lesson.session?.let { SessionBadge(it) }
Text(lesson.subject, style = MaterialTheme.typography.titleLargeEmphasized, maxLines = 3, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
TimeText(slot.timeLabel(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -543,18 +544,20 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
),
verticalAlignment = Alignment.CenterVertically,
) {
SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(18.dp))
val session = lesson.session
if (session != null) SessionDot(session, 20.dp, Modifier.padding(end = 4.dp)) else SubjectDot(lesson.subject, 10.dp)
Spacer(Modifier.width(if (session != null) 10.dp else 18.dp))
Column {
Text(lesson.subject, style = MaterialTheme.typography.titleSmallEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis)
TimeText(
listOfNotNull(
session?.label,
slot.timeLabel(),
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
lesson.type.takeIf { show.tags && it.isNotEmpty() }?.lowercase(),
lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(),
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = if (session != null) sessionColors().accent else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -145,7 +145,14 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val open = all.count { (it.task && !it.done) || (!it.task && parseDate(it.date) >= first) }
val lessonsOf: (LocalDate) -> List<Slot> = { d ->
val t = timetable
if (prefs.lessons && t != null) vm.slotsOn(t, d, settings?.subgroup) else emptyList()
when {
t == null -> emptyList()
prefs.lessons -> vm.slotsOn(t, d, settings?.subgroup)
// Exams, credits and consultations are deadlines too: they stay in the plans.
else -> vm.slotsOn(t, d, settings?.subgroup)
.map { it.copy(lessons = it.lessons.filter { l -> l.session != null }) }
.filter { it.lessons.isNotEmpty() }
}
}
val agenda = prefs.layout == PlansLayout.Agenda
val week = timetable?.days?.firstOrNull { it.date == date.monday().toString() }?.week
@@ -374,8 +381,10 @@ private fun LessonsSummary(slots: List<Slot>, modifier: Modifier = Modifier, col
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
slots.forEach { slot ->
slot.lessons.forEach { l ->
val session = l.session
androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(3.dp).height(14.dp).background(subjectColor(l.subject).container, CircleShape))
if (session != null) SessionDot(session, 18.dp)
else Box(Modifier.width(3.dp).height(14.dp).background(subjectColor(l.subject).container, CircleShape))
Spacer(Modifier.width(8.dp))
Text(
"${slot.start}–${slot.end}",
@@ -383,7 +392,14 @@ private fun LessonsSummary(slots: List<Slot>, modifier: Modifier = Modifier, col
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(10.dp))
Text(l.subject, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
(session?.let { "${it.label}: " } ?: "") + l.subject +
(l.subgroup?.let { " · ${it.lowercase().replace("подгруппа", "подгр.")}" } ?: ""),
style = if (session != null) MaterialTheme.typography.bodyMediumEmphasized else MaterialTheme.typography.bodyMedium,
color = if (session != null) sessionColors().accent else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View File

@@ -435,8 +435,11 @@ fun LessonCard(
) {
val color = subjectColor(lesson.subject)
val show = LocalDisplay.current
val session = lesson.session
val content: @Composable () -> Unit = {
Column(Modifier.padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Exams, credits and consultations announce themselves before the subject.
session?.let { SessionBadge(it, Modifier.padding(bottom = 2.dp)) }
Row(verticalAlignment = Alignment.Top) {
Text(lesson.subject, style = MaterialTheme.typography.titleMediumEmphasized, modifier = Modifier.weight(1f))
status?.let {
@@ -461,7 +464,7 @@ fun LessonCard(
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
}
val tags = if (!show.tags) emptyList() else listOfNotNull(
lesson.type.ifEmpty { null },
lesson.type.ifEmpty { null }?.takeIf { session == null },
if (lesson.variant != "all") (if (lesson.variant == "upper") "белые недели" else "зелёные недели") else null,
if (lesson.groups.size > 1) "общее занятие" else null,
)
@@ -481,7 +484,11 @@ fun LessonCard(
if (date != null) NotesHint(LocalNotes.current.forLesson(lesson.subject, date, lesson.start), color.onSoft)
}
}
val border = if (running) BorderStroke(2.dp, color.onSoft) else null
val border = when {
running -> BorderStroke(2.dp, color.onSoft)
session != null -> BorderStroke(2.dp, session.outline())
else -> null
}
if (onClick != null) Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
@@ -531,7 +538,8 @@ private fun TypeTag(type: String) {
val (bg, fg) = when (type) {
"Лекция" -> scheme.primaryContainer to scheme.onPrimaryContainer
"Практика" -> scheme.tertiaryContainer to scheme.onTertiaryContainer
"Экзамен", "Зачёт" -> scheme.errorContainer to scheme.onErrorContainer
"Экзамен", "Зачёт", "Защита курсовой работы" -> sessionColors().let { it.strong to it.onStrong }
"Консультация" -> sessionColors().let { it.soft to it.onSoft }
else -> scheme.secondaryContainer to scheme.onSecondaryContainer
}
Surface(shape = MaterialTheme.shapes.small, color = bg, contentColor = fg) {

View File

@@ -0,0 +1,109 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CoPresent
import androidx.compose.material.icons.rounded.Forum
import androidx.compose.material.icons.rounded.Quiz
import androidx.compose.material.icons.rounded.TaskAlt
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.ui.theme.harmonizeWith
/**
* The session's events: exams, credits, consultations and coursework defences. They wear an
* amber of their own — red would read as an error, and the tertiary accent already marks plans
* in the calendar — and each kind its own icon, so they stand out on every screen.
*/
enum class SessionKind(val label: String, val icon: ImageVector, val strong: Boolean) {
Exam("Экзамен", Icons.Rounded.Quiz, strong = true),
Credit("Зачёт", Icons.Rounded.TaskAlt, strong = true),
Defence("Защита курсовой", Icons.Rounded.CoPresent, strong = true),
Consultation("Консультация", Icons.Rounded.Forum, strong = false),
}
val Lesson.session: SessionKind?
get() = when (type) {
"Экзамен" -> SessionKind.Exam
"Зачёт" -> SessionKind.Credit
"Защита курсовой работы" -> SessionKind.Defence
"Консультация" -> SessionKind.Consultation
else -> null
}
/**
* The session amber as an M3 tonal palette (seed #8A5100): `accent` for text, marks and outlines,
* `strong`/`onStrong` for filled badges, `soft`/`onSoft` for tonal ones. Harmonized with the
* theme's primary like the subject colours, so it also sits well with wallpaper colours.
*/
class SessionColors(val accent: Color, val strong: Color, val onStrong: Color, val soft: Color, val onSoft: Color)
@Composable
fun sessionColors(): SessionColors {
val scheme = MaterialTheme.colorScheme
val dark = scheme.surface.luminance() < 0.5f
val p = scheme.primary
return if (dark) SessionColors(
accent = Color(0xFFFFB95C).harmonizeWith(p),
strong = Color(0xFFFFB95C).harmonizeWith(p),
onStrong = Color(0xFF482900).harmonizeWith(p),
soft = Color(0xFF693C00).harmonizeWith(p),
onSoft = Color(0xFFFFDDB8).harmonizeWith(p),
) else SessionColors(
accent = Color(0xFF8A5100).harmonizeWith(p),
strong = Color(0xFF8A5100).harmonizeWith(p),
onStrong = Color.White,
soft = Color(0xFFFFDDB8).harmonizeWith(p),
onSoft = Color(0xFF2C1600).harmonizeWith(p),
)
}
/** Fill and content: exams, credits and defences strong, consultations tonal. */
@Composable
fun SessionKind.colors(): Pair<Color, Color> {
val c = sessionColors()
return if (strong) c.strong to c.onStrong else c.soft to c.onSoft
}
/** The outline a session event's card or block gets. */
@Composable
fun SessionKind.outline(): Color = sessionColors().accent
/** "Зачёт" with its icon, as a pill above the subject. */
@Composable
fun SessionBadge(kind: SessionKind, modifier: Modifier = Modifier) {
val (bg, fg) = kind.colors()
Surface(shape = CircleShape, color = bg, contentColor = fg, modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp)) {
Icon(kind.icon, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(5.dp))
Text(kind.label, style = MaterialTheme.typography.labelLargeEmphasized)
}
}
}
/** A round marker with the kind's icon, in place of a subject's dot. */
@Composable
fun SessionDot(kind: SessionKind, size: Dp = 20.dp, modifier: Modifier = Modifier) {
val (bg, fg) = kind.colors()
Box(modifier.size(size).background(bg, CircleShape), contentAlignment = Alignment.Center) {
Icon(kind.icon, contentDescription = null, tint = fg, modifier = Modifier.size(size * 0.65f))
}
}

View File

@@ -174,6 +174,7 @@ fun WeekGrid(
@Composable
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject)
val session = lesson.session
val room = when {
lesson.distance -> "дист."
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ")
@@ -184,6 +185,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
shape = MaterialTheme.shapes.small,
color = color.container,
contentColor = color.content,
border = session?.let { androidx.compose.foundation.BorderStroke(2.dp, it.outline()) },
modifier = modifier.semantics {
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
@@ -194,6 +196,7 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, o
if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
Box(Modifier.size(8.dp).background(color.content, CircleShape))
}
session?.let { Box(Modifier.fillMaxSize().padding(3.dp), contentAlignment = Alignment.BottomEnd) { SessionDot(it, 18.dp) } }
Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
if (wide) lesson.subject else abbreviate(lesson.subject),