feat: read "Занятия в Полоцке" as a day's town, show it in all clients

- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 01:26:54 +03:00
parent 3d08d0cb70
commit 10c85e3bb9
50 changed files with 808 additions and 80 deletions

View File

@@ -68,6 +68,7 @@ private fun fields(a: ChangedLesson, b: ChangedLesson): List<FieldChange> {
("Вид занятия" to (a.lesson.type to b.lesson.type)),
("Преподаватель" to (a.lesson.teachers.joinToString(", ") to b.lesson.teachers.joinToString(", "))),
("Аудитория" to (a.lesson.rooms.joinToString(", ") to b.lesson.rooms.joinToString(", "))),
("Город" to (a.lesson.place.orEmpty() to b.lesson.place.orEmpty())),
).filter { (_, values) -> values.first.trim() != values.second.trim() }
.map { (label, values) -> FieldChange(label, shown(values.first), shown(values.second)) }
}

View File

@@ -40,10 +40,29 @@ data class Week(val n: Int, val color: String) {
}
@Serializable
data class Day(val date: String, val weekday: Int, val week: Week? = null, val lessons: List<Lesson> = emptyList())
data class Day(
val date: String,
val weekday: Int,
val week: Week? = null,
val lessons: List<Lesson> = emptyList(),
val places: List<DayPlace> = emptyList(),
)
@Serializable
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList())
data class WeeklyDay(val day: Int, val lessons: List<Lesson> = emptyList(), val places: List<DayPlace> = emptyList())
/**
* "Занятия в Полоцке" over a day in Word: that day the group's lessons (or one subgroup's,
* when `subgroup` is set) are in another town (server/timetable.js placesOn).
*/
@Serializable
data class DayPlace(val place: String, val text: String, val subgroup: String? = null)
/** The notes for the student's subgroup; a note for their own subgroup reads as the whole day's. */
fun List<DayPlace>.forSubgroup(subgroup: String?): List<DayPlace> =
filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.map { if (subgroup != null) it.copy(subgroup = null) else it }
.distinct()
@Serializable
data class Lesson(
@@ -56,6 +75,8 @@ data class Lesson(
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
val roomMoved: Boolean = false,
/** The town, when the cell or the day's note names one: "Полоцк". */
val place: String? = null,
val distance: Boolean = false,
val condition: String = "",
val variant: String = "all",
@@ -68,6 +89,10 @@ data class Lesson(
/** A recurring lesson the student may hide: the subject with its kind, in every week. */
val Lesson.hideKey: String get() = subject.trim().lowercase() + "|" + type.trim().lowercase()
/** A room label with the lesson's town when the document names one: "ауд. 203Г · Полоцк", or the town alone. */
fun withPlace(room: String?, place: String?): String? =
listOfNotNull(room?.takeIf { it.isNotEmpty() }, place).joinToString(" · ").ifEmpty { null }
@Serializable
data class GroupsResponse(
val results: List<GroupResult> = emptyList(),
@@ -118,6 +143,7 @@ data class DirectoryLesson(
val teachers: List<String> = emptyList(),
val rooms: List<String> = emptyList(),
val moved: Boolean = false,
val place: String? = null,
val distance: Boolean = false,
val groups: List<String> = emptyList(),
val condition: String = "",

View File

@@ -114,4 +114,14 @@ class Repository(context: Context, val api: Api, private val settings: Settings)
fun dayOf(timetable: Timetable, date: LocalDate): Day? =
timetable.days.firstOrNull { it.date == date.toString() }
/**
* "Занятия в Полоцке" on the date for the student's subgroup, and only on a day they have
* lessons. A note for their own subgroup reads as the whole day's.
*/
fun placesOn(timetable: Timetable, date: LocalDate, subgroup: String?, hidden: Set<String> = emptySet()): List<DayPlace> {
val day = dayOf(timetable, date) ?: return emptyList()
if (day.places.isEmpty() || lessonsOn(timetable, date, subgroup, hidden).isEmpty()) return emptyList()
return day.places.forSubgroup(subgroup)
}
}

View File

@@ -1,6 +1,7 @@
package org.xtimms.psu.schedule.notify
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.withPlace
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
@@ -85,11 +86,14 @@ object Reminders {
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
val first = lessons.first()
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
val place = withPlace(
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
lessons.firstNotNullOfOrNull { it.place },
)
val text = buildString {
append(start)
first.pair?.let { append(" · $it пара") }
if (place.isNotEmpty()) append(" · ауд. $place")
place?.let { append(" · $it") }
if (lessons.any { it.uncertain }) append(" · сверьте недели")
}
val pending = intent(context, slot) {

View File

@@ -32,7 +32,7 @@ class LessonTileService : TileService() {
tile.state = Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = "на ближайшую неделю"
} else {
val room = next.rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
val room = next.where
tile.label = listOfNotNull(next.whenLabel(), room).joinToString(" · ")
tile.state = if (next.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tile.subtitle = next.subject

View File

@@ -95,6 +95,7 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.theme.page
import java.time.LocalDate
import java.time.YearMonth
@@ -468,6 +469,7 @@ private fun DayAgendaContent(
val plans = notes.filter { it.date == date.toString() }.sortedWith(compareBy({ it.task && it.done }, { it.start }))
val known = t?.days?.any { it.date == date.toString() } == true
val slots = if (t != null && known) vm.slotsOn(t, date, subgroup) else emptyList()
val places = if (t != null && known) vm.placesOn(t, date, subgroup) else emptyList()
val lessons = slots.flatMap { slot -> slot.lessons.map { slot to it } }
if (plans.isEmpty() && lessons.isEmpty()) {
@@ -519,6 +521,7 @@ private fun DayAgendaContent(
item(key = "lessons") { AnimatedItem {
Section(number, "Ваши занятия", trailing = if (number == 1) date.format(DATE) else null, gap = number > 1)
} }
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places, Modifier.padding(top = 8.dp, bottom = 12.dp)) } }
lessons.forEachIndexed { i, (slot, l) ->
item(key = "${slot.start}|${l.key}") {
val color = subjectColor(l.subject)
@@ -532,7 +535,7 @@ private fun DayAgendaContent(
overColor = if (session != null) sessionColors().accent else null,
title = l.subject,
under = listOfNotNull(
if (l.distance) "дистанционно" else l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
if (l.distance) "дистанционно" else withPlace(l.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, l.place),
l.type.ifEmpty { null }?.takeIf { session == null }?.lowercase(),
).joinToString(" · ").ifEmpty { null },
last = i == lessons.lastIndex,

View File

@@ -36,6 +36,7 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.theme.card
import java.time.Instant
import java.time.ZonedDateTime
@@ -106,7 +107,7 @@ fun ChangesSheet(record: ScheduleChanges) {
if (record.added.isNotEmpty()) {
Heading("Добавлено · ${record.added.size}", modifier = Modifier.padding(top = 8.dp))
record.added.forEach { row ->
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.condition))
ChangeItem(row, details = listOf(row.lesson.type, row.lesson.teachers.joinToString(", "), withPlace(row.lesson.rooms.joinToString(", ") { "ауд. $it" }, row.lesson.place).orEmpty(), row.lesson.condition))
}
}
if (record.removed.isNotEmpty()) {

View File

@@ -86,6 +86,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.longLabel
@@ -97,6 +98,7 @@ import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekSpan
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
@@ -226,7 +228,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "week") { AnimatedItem { WeekReport(vm, t, subgroup, date, clock) } }
item(key = "session") { AnimatedItem { SessionCard(vm, t, onOpen = { session = true }) } }
item(key = "today-title") { AnimatedItem { DayTitle("Сегодня", date) } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
item(key = "today") { AnimatedItem { TodayCard(todaySlots, vm.placesOn(t, date, subgroup), clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) } }
item(key = "attendance") { AnimatedItem { AttendanceCard(vm, todaySlots) } }
item(key = "tasks") { AnimatedItem { TasksCard(vm, onEdit = { editing = it }, onAll = nav.openPlans) } }
if (next != null) {
@@ -234,6 +236,7 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item(key = "next-title") { AnimatedItem { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) } }
item(key = "next") {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
DayPlaces(vm.placesOn(t, day, subgroup))
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
DayButton { nav.openDay(day) }
}
@@ -444,7 +447,7 @@ private fun DayTitle(label: String, date: LocalDate) {
}
@Composable
private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
private fun TodayCard(slots: List<Slot>, places: List<DayPlace>, clock: LocalTime, onOpen: (Lesson) -> Unit, onDay: () -> Unit) {
val left = slots.filter { it.end.isAfter(clock) }
SectionCard(Icons.Rounded.Schedule, "Следующие занятия") {
when {
@@ -454,6 +457,7 @@ private fun TodayCard(slots: List<Slot>, clock: LocalTime, onOpen: (Lesson) -> U
"Сегодня было ${slots.size} ${plural(slots.size, "пара", "пары", "пар")}.",
)
else -> {
DayPlaces(places)
val first = left.first()
first.lessons.forEach { FeaturedLesson(it, first, clock, onClick = { onOpen(it) }) }
if (left.size > 1) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@@ -541,7 +545,7 @@ private fun FeaturedLesson(lesson: Lesson, slot: Slot, clock: LocalTime, onClick
}
}
val show = LocalDisplay.current
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
room?.let { HomeMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) HomeMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
}
@@ -570,7 +574,7 @@ fun CompactLesson(lesson: Lesson, slot: Slot, onClick: (() -> Unit)?) {
listOfNotNull(
session?.label,
slot.timeLabel(),
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" },
if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. $it" }, lesson.place),
lesson.type.takeIf { show.tags && it.isNotEmpty() && session == null }?.lowercase(),
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,

View File

@@ -0,0 +1,80 @@
package org.xtimms.psu.schedule.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationCity
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.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Timetable
import java.time.LocalDate
// "Занятия в Полоцке": Word marks the days a group spends in another town over the day's first
// row. The server reads it as a note of the day, not a lesson, and gives each lesson its town.
fun MainViewModel.placesOn(t: Timetable, date: LocalDate, subgroup: String?): List<DayPlace> =
repository.placesOn(t, date, subgroup, hidden)
/** The day's note above its lessons: the town in a tertiary card, with the subgroup it is for. */
@Composable
fun DayPlaces(places: List<DayPlace>, modifier: Modifier = Modifier) {
if (places.isEmpty()) return
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
places.forEach { p ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.LocationCity, contentDescription = null, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column {
Text(p.text, style = MaterialTheme.typography.titleMediumEmphasized)
Text(
listOfNotNull(p.subgroup, "пометка в Word на этот день").joinToString(" · ").replaceFirstChar { it.titlecase() },
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
/** The town under a day's date in the week grid: short, in the note's colour. */
@Composable
fun DayPlaceMark(places: List<DayPlace>, modifier: Modifier = Modifier) {
val first = places.firstOrNull() ?: return
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = modifier.semantics { contentDescription = places.joinToString(", ") { listOfNotNull(it.text, it.subgroup).joinToString(", ") } },
) {
Text(
first.place,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}

View File

@@ -69,6 +69,7 @@ import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.EventBusy
import androidx.compose.material.icons.rounded.EventRepeat
import androidx.compose.material.icons.rounded.Groups
import androidx.compose.material.icons.rounded.LocationCity
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Refresh
@@ -101,13 +102,16 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.data.Bell
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.dayName
import org.xtimms.psu.schedule.data.forSubgroup
import org.xtimms.psu.schedule.data.minutesLabel
import org.xtimms.psu.schedule.data.monday
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
@@ -384,6 +388,7 @@ private fun Planner(
DayPage(
date = pageDate,
slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
onOpen = onOpen,
)
}
@@ -402,6 +407,7 @@ private fun Planner(
date = pageDate,
week = t.days.firstOrNull { it.date == pageDate.toString() }?.week,
slots = vm.slotsOn(t, pageDate, subgroup),
places = vm.placesOn(t, pageDate, subgroup),
chosen = stand?.takeIf { it.first == pageDate }?.second,
clock = clock,
onOpen = { onOpen(it, pageDate) },
@@ -431,7 +437,7 @@ private fun Planner(
}
@Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDate) -> Unit) {
private fun DayPage(date: LocalDate, slots: List<Slot>, places: List<DayPlace>, onOpen: (Lesson, LocalDate) -> Unit) {
val clock = rememberClock()
val isToday = date == today()
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
@@ -446,6 +452,8 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDa
Empty("Занятий нет", "По таблице недель в этот день свободно — можно отдохнуть.", icon = Icons.Rounded.Weekend)
}
}
// "Занятия в Полоцке": the day's town before its lessons.
if (places.isNotEmpty()) item(key = "places") { AnimatedItem { DayPlaces(places) } }
slots.forEach { slot ->
val running = isToday && !clock.isBefore(slot.start) && clock.isBefore(slot.end)
val status = when {
@@ -508,10 +516,10 @@ fun LessonCard(
time?.let { TimeText(it + (pair?.takeIf { show.pairs }?.let { p -> " · $p пара" } ?: ""), style = MaterialTheme.typography.bodyLarge) }
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (lesson.distance) Meta(Icons.Rounded.LocationOn, "Дистанционно")
else if (show.rooms && lesson.rooms.isNotEmpty()) Meta(
Icons.Rounded.LocationOn,
lesson.rooms.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "",
)
else withPlace(
lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { it.joinToString(", ") + if (lesson.roomMoved) " (перенос)" else "" },
lesson.place,
)?.let { Meta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) Meta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
lesson.subgroup?.let { Meta(Icons.Rounded.Groups, it) }
}
@@ -644,6 +652,7 @@ internal fun LessonDetails(
if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { "Не указана" },
if (lesson.roomMoved) "В этот день — перенос аудитории" else null,
)
lesson.place?.let { Detail(Icons.Rounded.LocationCity, "Город", it, "Так указано в Word") }
Detail(Icons.Rounded.Groups, "Группы", lesson.groups.joinToString(", "), lesson.subgroup)
if (lesson.uncertain) Banner(
"Пометку «${lesson.condition}» не удалось сопоставить с таблицей недель. Сверьте с Word.",
@@ -702,6 +711,8 @@ private fun WordRows(t: Timetable, subgroup: String?, hidden: Set<String>, onOpe
}
val lessons = day.lessons.filter { subgroup == null || it.subgroup == null || it.subgroup == subgroup }
.filter { it.hideKey !in hidden }
val places = if (lessons.isEmpty()) emptyList() else day.places.forSubgroup(subgroup)
if (places.isNotEmpty()) item(key = "p${day.day}") { DayPlaces(places) }
items(lessons, key = { "${day.day}-${it.key}" }) { lesson ->
LessonCard(lesson, time = lesson.start + (lesson.end?.let { " → $it" } ?: ""), onClick = { onOpen(lesson) })
}

View File

@@ -261,6 +261,7 @@ fun SearchScreen(vm: MainViewModel, nav: AppNav) {
teachers = if (target is Target.Room) l.teachers else emptyList(),
rooms = if (target is Target.Teacher) l.rooms else emptyList(),
roomMoved = l.moved,
place = l.place,
distance = l.distance,
condition = l.condition,
groups = l.groups,

View File

@@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FilledTonalButton
import org.xtimms.psu.schedule.data.Note
import org.xtimms.psu.schedule.data.withPlace
import androidx.compose.foundation.background
import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.material.icons.rounded.Event
@@ -283,7 +284,7 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
Fact(
Icons.Rounded.Event,
next?.let { (slot, lesson) ->
nextLabel(slot) + (lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { ", ауд. ${it.joinToString(", ")}" } ?: "")
nextLabel(slot) + (withPlace(lesson.rooms.takeIf { show.rooms && it.isNotEmpty() }?.let { "ауд. ${it.joinToString(", ")}" }, lesson.place)?.let { ", $it" } ?: "")
} ?: "В загруженных неделях больше нет",
strong = next != null,
)
@@ -360,7 +361,7 @@ private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
Text(
listOfNotNull(
lesson.type.ifEmpty { null },
if (lesson.distance) "дистанционно" else lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" },
if (lesson.distance) "дистанционно" else withPlace(lesson.rooms.joinToString(", ").ifEmpty { null }?.let { "ауд. $it" }, lesson.place),
lesson.teachers.joinToString(", ").ifEmpty { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,

View File

@@ -37,7 +37,9 @@ import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.xtimms.psu.schedule.data.DayPlace
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.data.Week
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.minutesLabel
@@ -65,6 +67,7 @@ fun StandDay(
date: LocalDate,
week: Week?,
slots: List<Slot>,
places: List<DayPlace>,
chosen: LocalTime?,
clock: LocalTime,
onOpen: (Lesson) -> Unit,
@@ -86,7 +89,11 @@ fun StandDay(
}
Text(
if (slots.isEmpty()) "Пар нет"
else "${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
else listOfNotNull(
"${slots.size} ${plural(slots.size, "пара", "пары", "пар")} · ${slots.first().start}–${slots.last().end}",
// "Занятия в Полоцке", readable from across the desk with the day's span.
places.joinToString(", ") { listOfNotNull(it.text, it.subgroup?.lowercase()).joinToString(", ") }.ifEmpty { null },
).joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp),
@@ -158,14 +165,14 @@ private fun StandLesson(slot: Slot, clock: LocalTime, isToday: Boolean, onOpen:
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
val room = if (lesson.distance) "Дистанционно" else lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }
val room = if (lesson.distance) "Дистанционно" else withPlace(lesson.rooms.joinToString(", ").takeIf { show.rooms && it.isNotEmpty() }?.let { "Ауд. $it" }, lesson.place)
room?.let { StandMeta(Icons.Rounded.LocationOn, it) }
if (show.teachers && lesson.teachers.isNotEmpty()) StandMeta(Icons.Rounded.Person, lesson.teachers.joinToString(", "))
}
// Parallel subgroups share the slot: the others in a line.
slot.lessons.drop(1).forEach { other ->
Text(
listOfNotNull(other.subgroup, other.subject, other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" })
listOfNotNull(other.subgroup, other.subject, withPlace(other.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, other.place))
.joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
@@ -271,7 +278,8 @@ fun StandLessons(
color = if (past && !selected) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified,
)
val room = slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ")
if (room.isNotEmpty()) Text("ауд. $room", style = MaterialTheme.typography.bodySmall, maxLines = 1)
withPlace(room.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, slot.lessons.firstNotNullOfOrNull { it.place })
?.let { Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1) }
}
}
}

View File

@@ -45,6 +45,7 @@ import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.LocalDate
import java.time.LocalTime
@@ -88,6 +89,7 @@ fun WeekGrid(
) {
days.forEach { (d, _) ->
val isToday = d == today()
val places = vm.placesOn(t, d, subgroup)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -113,6 +115,8 @@ fun WeekGrid(
color = if (isToday) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
)
}
// "Занятия в Полоцке": the town of the whole day, under its date.
DayPlaceMark(places, Modifier.padding(top = 2.dp, start = 2.dp, end = 2.dp))
}
}
}
@@ -175,10 +179,12 @@ fun WeekGrid(
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 rooms = lesson.rooms.joinToString(", ").takeIf { LocalDisplay.current.rooms }.orEmpty()
// A narrow column keeps the room whole: the day's town is under its date.
val room = when {
lesson.distance -> "дист."
LocalDisplay.current.rooms -> lesson.rooms.joinToString(", ")
else -> ""
wide -> withPlace(rooms, lesson.place).orEmpty()
else -> rooms.ifEmpty { lesson.place.orEmpty() }
}
Surface(
onClick = onClick,

View File

@@ -69,11 +69,13 @@ class NextLessonWidget : GlanceAppWidget() {
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 15.sp, fontWeight = FontWeight.Bold),
)
if (next.rooms.isNotEmpty()) Text(
"ауд. ${next.rooms}",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
)
next.where?.let { where ->
Text(
where,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 13.sp),
)
}
}
}
}

View File

@@ -34,6 +34,7 @@ import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.weekdayName
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
@@ -71,8 +72,11 @@ private suspend fun state(context: Context): WidgetState {
"$start · через ${minutesLabel(Duration.between(now, begins).toMinutes())}"
else -> start + (l.pair?.let { " · $it пара" } ?: "")
}
val place = lessons.flatMap { it.rooms }.distinct().joinToString(", ")
label to (lessons.joinToString(" / ") { it.subject } + if (place.isNotEmpty()) " · ауд. $place" else "")
val place = withPlace(
lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
lessons.firstNotNullOfOrNull { it.place },
)
label to (lessons.joinToString(" / ") { it.subject } + (place?.let { " · $it" } ?: ""))
}
return WidgetState(
timetable.group,

View File

@@ -8,6 +8,7 @@ import org.xtimms.psu.schedule.data.nowTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.shortWeekday
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Duration
import java.time.LocalDate
@@ -15,6 +16,8 @@ import java.time.LocalDate
data class Upcoming(val date: LocalDate, val start: String, val end: String?, val lessons: List<Lesson>, val running: Boolean) {
val subject get() = lessons.joinToString(" / ") { it.subject }
val rooms get() = lessons.flatMap { if (it.distance) listOf("дист.") else it.rooms }.distinct().joinToString(", ")
/** "ауд. 203Г · Полоцк": the rooms with the town the document names. */
val where get() = withPlace(rooms.takeIf { it.isNotEmpty() }?.let { "ауд. $it" }, lessons.firstNotNullOfOrNull { it.place })
/** "Идёт · ещё 40 мин", "Через 20 мин", "Завтра, 10:05" or "Пн, 10:05". */
fun whenLabel(): String {