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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user