feat: add tasks and notes management for lessons

- Introduced a new feature for managing tasks and notes associated with lessons.
- Added a `NotesStore` class to handle local storage of notes in JSON format.
- Implemented UI components for displaying, adding, editing, and deleting notes.
- Enhanced the `HomeScreen`, `ScheduleScreen`, and `SubjectsScreen` to integrate notes functionality.
- Updated the API to handle versioning and added error handling for outdated endpoints.
- Improved the overall user experience with visual indicators for tasks and notes in the schedule.
This commit is contained in:
2026-10-17 13:35:49 +03:00
parent e1a787c1dd
commit 29df0aa61f
18 changed files with 384859 additions and 22 deletions

View File

@@ -1 +1 @@
{"version":1,"sessions":{"01a13c7f-2064-7712-a08f-c0512bba9197":{"updatedAt":1792083512470,"files":{"/Users/xtimms/Projects/psu-schedule/src/App.tsx":{"editCount":1,"findings":[],"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]}}},"517f765a-68b5-47d1-b966-31dd338440a6":{"updatedAt":1792198645900,"files":{"/Users/xtimms/Projects/psu-schedule/src/icons.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]},"/Users/xtimms/Projects/psu-schedule/shared/weeks.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/calendar.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/calendar.ts":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/source.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/sources.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/lessons.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/schedules.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/directory.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Search.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/test/directory.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Groups.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/server/timetable.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}}},"footerShown":true}}}
{"version":1,"sessions":{"01a13c7f-2064-7712-a08f-c0512bba9197":{"updatedAt":1792083512470,"files":{"/Users/xtimms/Projects/psu-schedule/src/App.tsx":{"editCount":1,"findings":[],"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]}}},"517f765a-68b5-47d1-b966-31dd338440a6":{"updatedAt":1792233328745,"files":{"/Users/xtimms/Projects/psu-schedule/src/icons.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/src/styles.css":{"editCount":0,"findings":[]},"/Users/xtimms/Projects/psu-schedule/shared/weeks.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/calendar.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/calendar.ts":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/source.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/sources.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/test/lessons.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/schedules.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/server/directory.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Search.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/test/directory.test.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}},"/Users/xtimms/Projects/psu-schedule/src/Groups.tsx":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}},"cleanAcked":true},"/Users/xtimms/Projects/psu-schedule/server/timetable.js":{"editCount":1,"findings":[],"stopBaseline":{"version":1,"engine":"0.1.15","counts":{}}}},"footerShown":true}}}

View File

@@ -103,6 +103,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
- Группы (боковое меню): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
- Поиск по университету: преподаватель или аудитория на день, свободные аудитории на пару.

Binary file not shown.

384295
android/app/release/mapping.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "ritm.schedule",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "0.1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@@ -74,6 +74,7 @@ import ritm.schedule.ui.AppNav
import ritm.schedule.ui.GroupsScreen
import ritm.schedule.ui.HomeScreen
import ritm.schedule.ui.LocalDisplay
import ritm.schedule.ui.LocalNotes
import ritm.schedule.ui.LocalSubjectSlots
import ritm.schedule.data.Display
import ritm.schedule.ui.MainViewModel
@@ -164,7 +165,12 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
openMenu = if (wide) null else ({ scope.launch { menu.expand() } }),
)
}
CompositionLocalProvider(LocalSubjectSlots provides slots, LocalDisplay provides (settings?.display ?: Display())) {
val notes by vm.notes.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalSubjectSlots provides slots,
LocalDisplay provides (settings?.display ?: Display()),
LocalNotes provides notes,
) {
AnimatedContent(
targetState = page,
transitionSpec = {

View File

@@ -2,6 +2,7 @@ package ritm.schedule
import android.app.Application
import ritm.schedule.data.Api
import ritm.schedule.data.NotesStore
import ritm.schedule.data.Repository
import ritm.schedule.data.Settings
import ritm.schedule.notify.Reminders
@@ -12,11 +13,14 @@ class ScheduleApp : Application() {
private set
lateinit var repository: Repository
private set
lateinit var notes: NotesStore
private set
override fun onCreate() {
super.onCreate()
settings = Settings(this)
repository = Repository(this, Api(BuildConfig.API_URL), settings)
notes = NotesStore(this)
Reminders.createChannel(this)
SyncWorker.schedule(this)
}

View File

@@ -41,6 +41,10 @@ class Api(baseUrl: String) {
val message = runCatching {
json.parseToJsonElement(text).jsonObject["error"]?.jsonPrimitive?.content
}.getOrNull()
// The server's catch-all 404 means it runs an older version without this endpoint.
if (response.code == 404 && path.startsWith("api/") && message == "Адрес не найден.") throw ApiException(
"Сервер расписания не знает запрос /$path — на нём старая версия. Обновите сервер.",
)
throw ApiException(message ?: "Сервер ответил ошибкой ${response.code}.")
}
text

View File

@@ -0,0 +1,59 @@
package ritm.schedule.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.File
import java.util.UUID
/**
* A homework task or a plain note the student pinned to one lesson: the subject,
* the lesson's date and start time. Tasks can be ticked off; notes just remind.
*/
@Serializable
data class Note(
val id: String = UUID.randomUUID().toString(),
/** "docId|group": notes of one group never show up in another. */
val group: String,
val subject: String,
val date: String,
val start: String,
val text: String,
val task: Boolean = true,
val done: Boolean = false,
val created: Long = System.currentTimeMillis(),
)
fun groupKey(g: SavedGroup) = "${g.docId}|${g.group}"
/** Notes live only on the device, in one small JSON file. */
class NotesStore(context: Context) {
private val file = File(context.filesDir, "notes.json")
private val lock = Mutex()
private val _notes = MutableStateFlow(read())
val notes: StateFlow<List<Note>> = _notes
private fun read(): List<Note> =
runCatching { json.decodeFromString<List<Note>>(file.readText()) }.getOrDefault(emptyList())
private suspend fun change(block: (List<Note>) -> List<Note>) = withContext(Dispatchers.IO) {
lock.withLock {
val next = block(_notes.value)
file.writeText(json.encodeToString(next))
_notes.value = next
}
}
suspend fun save(note: Note) = change { list ->
if (list.any { it.id == note.id }) list.map { if (it.id == note.id) note else it } else list + note
}
suspend fun delete(id: String) = change { list -> list.filterNot { it.id == id } }
suspend fun toggle(id: String) = change { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } }
}

View File

@@ -2,6 +2,10 @@
package ritm.schedule.ui
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.runtime.saveable.rememberSaveable
import ritm.schedule.data.Note
import ritm.schedule.data.parseDate
import ritm.schedule.ui.theme.card
import ritm.schedule.ui.theme.page
import androidx.compose.foundation.background
@@ -110,7 +114,8 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Lesson?>(null) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var editing by remember { mutableStateOf<Note?>(null) }
val subgroup = settings?.subgroup
val clock = rememberClock()
val date = today()
@@ -158,13 +163,14 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
item { QuickActions(nav, Modifier.bleed(16.dp)) }
item { WeekReport(vm, t, subgroup, date, clock) }
item { DayTitle("Сегодня", date) }
item { TodayCard(todaySlots, clock, onOpen = { details = it }, onDay = { nav.openDay(date) }) }
item { TodayCard(todaySlots, clock, onOpen = { details = it to date }, onDay = { nav.openDay(date) }) }
item { TasksCard(vm, onEdit = { editing = it }) }
if (next != null) {
val (day, slots) = next
item { DayTitle(if (day == date.plusDays(1)) "Завтра" else day.weekdayName(), day) }
item {
SectionCard(Icons.Rounded.EventAvailable, plural(slots.size, "пара", "пары", "пар").let { "${slots.size} $it" }) {
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it }) } }
slots.forEach { slot -> slot.lessons.forEach { CompactLesson(it, slot, onClick = { details = it to day }) } }
DayButton { nav.openDay(day) }
}
}
@@ -173,8 +179,18 @@ fun HomeScreen(vm: MainViewModel, nav: AppNav) {
}
}
}
editing?.let { note ->
NoteEditor(
target = null,
editing = note,
groupKey = note.group,
onSave = { vm.saveNote(it); editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDismiss = { editing = null },
)
}
details?.let { lesson ->
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson, timetable?.bells.orEmpty()) }
ModalBottomSheet(onDismissRequest = { details = null }) { LessonDetails(lesson.first, timetable?.bells.orEmpty(), lesson.second, vm) }
}
}
@@ -212,6 +228,37 @@ private fun Modifier.bleed(horizontal: Dp): Modifier = layout { measurable, cons
layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
}
/**
* Homework and notes ahead: open tasks (overdue ones flagged) and notes for today and later,
* soonest first. Ticked tasks stay struck through until their day has passed.
*/
@Composable
private fun TasksCard(vm: MainViewModel, onEdit: (Note) -> Unit) {
val notes by vm.notes.collectAsStateWithLifecycle()
val date = today()
val pending = notes
.filter { parseDate(it.date) >= date || (it.task && !it.done) }
.sortedWith(compareBy({ it.task && it.done }, { it.date }, { it.start }))
if (pending.isEmpty()) return
var expanded by rememberSaveable { mutableStateOf(false) }
val open = pending.count { !it.task || !it.done }
ListCard(
Icons.AutoMirrored.Rounded.Assignment,
"Задания и заметки",
trailing = {
Text("$open", style = MaterialTheme.typography.titleMediumEmphasized, color = MaterialTheme.colorScheme.onSurfaceVariant)
},
) {
(if (expanded) pending else pending.take(4)).forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { onEdit(note) }, showSubject = true)
}
if (pending.size > 4) TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.padding(start = 12.dp),
) { Text(if (expanded) "Свернуть" else "Показать все (${pending.size})") }
}
}
/** "Прошло 12 из 18 пар" with a wavy progress line over the current week. */
@Composable
private fun WeekReport(vm: MainViewModel, t: Timetable, subgroup: String?, date: LocalDate, clock: LocalTime) {

View File

@@ -7,6 +7,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import ritm.schedule.data.Note
import ritm.schedule.data.groupKey
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import ritm.schedule.app
@@ -31,6 +34,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
app.settings.flow.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val timetable: StateFlow<Timetable?> = app.repository.timetable
/** Tasks and notes of the open group. */
val notes: StateFlow<List<Note>> = combine(app.notes.notes, app.settings.flow) { list, s ->
val key = s.current?.let(::groupKey)
list.filter { it.group == key }
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
fun saveNote(note: Note) = viewModelScope.launch { app.notes.save(note) }
fun deleteNote(id: String) = viewModelScope.launch { app.notes.delete(id) }
fun toggleNote(id: String) = viewModelScope.launch { app.notes.toggle(id) }
/** The key new notes are filed under, or null before a group is chosen. */
fun currentGroupKey(): String? = settings.value?.current?.let(::groupKey)
private val _status = MutableStateFlow(Status())
val status = _status.asStateFlow()

View File

@@ -0,0 +1,294 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package ritm.schedule.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.StickyNote2
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ritm.schedule.data.Lesson
import ritm.schedule.data.Note
import ritm.schedule.data.Timetable
import ritm.schedule.data.longLabel
import ritm.schedule.data.nowTime
import ritm.schedule.data.parseDate
import ritm.schedule.data.shortWeekday
import ritm.schedule.data.today
import java.time.LocalDate
/** Tasks and notes of the open group, for cards deep in the tree. */
val LocalNotes = staticCompositionLocalOf<List<Note>> { emptyList() }
fun List<Note>.forLesson(subject: String, date: LocalDate, start: String): List<Note> {
val key = subjectKey(subject)
val day = date.toString()
return filter { it.date == day && it.start == start && subjectKey(it.subject) == key }
}
/** Unfinished tasks and notes: what a card should hint at. */
fun List<Note>.open(): List<Note> = filter { !it.task || !it.done }
fun Note.overdue(): Boolean = task && !done && parseDate(date) < today()
/** "к сегодня, 11:45", "к завтра, 10:05", "к пн, 19 октября". */
fun Note.dueLabel(): String {
val d = parseDate(date)
val day = when (d) {
today() -> "сегодня, $start"
today().plusDays(1) -> "завтра, $start"
else -> d.shortWeekday().lowercase() + ", " + d.longLabel()
}
return if (task) "к $day" else day
}
/** The next lesson of the same subject after this one, within the loaded weeks. */
fun MainViewModel.nextOccurrence(t: Timetable, subject: String, after: LocalDate, start: String, subgroup: String?): Pair<LocalDate, Lesson>? {
val key = subjectKey(subject)
return t.days.asSequence()
.map { parseDate(it.date) }
.filter { it >= after }
.flatMap { d ->
repository.lessonsOn(t, d, subgroup)
.filter { subjectKey(it.subject) == key && (d > after || it.start > start) }
.sortedBy { it.start }
.map { d to it }
}
.firstOrNull()
}
/** A task with its checkbox or a note with its icon; tap opens the editor. */
@Composable
fun NoteRow(note: Note, onToggle: () -> Unit, onClick: () -> Unit, showSubject: Boolean) {
val overdue = note.overdue()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Изменить", role = Role.Button, onClick = onClick)
.padding(start = 8.dp, end = 16.dp, top = 2.dp, bottom = 2.dp),
) {
if (note.task) Checkbox(checked = note.done, onCheckedChange = { onToggle() })
else Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.StickyNote2, contentDescription = "Заметка", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f).padding(vertical = 8.dp)) {
Text(
note.text,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (note.task && note.done) TextDecoration.LineThrough else null,
color = if (note.task && note.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (showSubject) {
SubjectDot(note.subject, 8.dp)
Spacer(Modifier.width(6.dp))
}
Text(
listOfNotNull(
if (showSubject) note.subject else null,
if (overdue) "просрочено" else note.dueLabel(),
).joinToString(" · "),
style = MaterialTheme.typography.bodyMedium,
color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/** What the editor needs to file a new note: the lesson it opened from and the next one. */
class NoteTarget(val subject: String, val date: LocalDate, val start: String, val next: Pair<LocalDate, Lesson>?)
/**
* Adds or edits a task or a note. A new one goes to this lesson or, for homework set in class,
* to the next lesson of the subject; once the lesson is over the next one is the default.
*/
@Composable
fun NoteEditor(
target: NoteTarget?,
editing: Note?,
groupKey: String,
onSave: (Note) -> Unit,
onDelete: (Note) -> Unit,
onDismiss: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf(editing?.text.orEmpty()) }
var task by rememberSaveable { mutableStateOf(editing?.task ?: true) }
val started = target != null && (target.date < today() || (target.date == today() && target.start <= nowTime().toString()))
var toNext by rememberSaveable { mutableStateOf(editing == null && started && target?.next != null) }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) { focus.requestFocus() }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(if (task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2, contentDescription = null) },
title = { Text(if (editing == null) (if (task) "Новое задание" else "Новая заметка") else (if (task) "Задание" else "Заметка")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ConnectedChoice(
options = listOf(true to "Задание", false to "Заметка"),
selected = task,
onSelect = { task = it },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = text,
onValueChange = { text = it },
placeholder = { Text(if (task) "Например, сдать лабораторную 3" else "Например, принести конспект") },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp).focusRequester(focus),
)
if (editing == null && target?.next != null) {
Text("К какому занятию", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
val (nextDate, nextLesson) = target.next
ConnectedChoice(
options = listOf(false to "Эта пара", true to "Следующая"),
selected = toNext,
onSelect = { toNext = it },
modifier = Modifier.fillMaxWidth(),
)
val (day, start) = if (toNext) nextDate to nextLesson.start else target.date to target.start
Text(
day.shortWeekday() + ", " + day.longLabel() + ", " + start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
TextButton(
enabled = text.isNotBlank(),
onClick = {
val base = editing ?: run {
val t = target ?: return@TextButton
val (date, start) = if (toNext && t.next != null) t.next.first to t.next.second.start else t.date to t.start
Note(group = groupKey, subject = t.subject, date = date.toString(), start = start, text = "")
}
onSave(base.copy(text = text.trim(), task = task))
},
) { Text("Сохранить") }
},
dismissButton = {
Row {
if (editing != null) TextButton(onClick = { onDelete(editing) }) {
Text("Удалить", color = MaterialTheme.colorScheme.error)
}
TextButton(onClick = onDismiss) { Text("Отмена") }
}
},
)
}
/** The lesson sheet's section: this lesson's tasks and notes and a button to add one. */
@Composable
fun LessonNotes(vm: MainViewModel, lesson: Lesson, date: LocalDate) {
val all by vm.notes.collectAsStateWithLifecycle()
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val group = vm.currentGroupKey() ?: return
val notes = all.forLesson(lesson.subject, date, lesson.start)
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Heading("Задания и заметки", style = MaterialTheme.typography.titleSmallEmphasized)
if (notes.isEmpty()) Text(
"Запишите домашнее задание или напоминание — оно появится в карточке пары и в обзоре.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notes.forEach { note ->
NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false)
}
FilledTonalButton(onClick = { adding = true }, modifier = Modifier.padding(top = 8.dp)) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Добавить")
}
}
if (adding || editing != null) {
val t = timetable
NoteEditor(
target = NoteTarget(
subject = lesson.subject,
date = date,
start = lesson.start,
next = t?.let { vm.nextOccurrence(it, lesson.subject, date, lesson.start, settings?.subgroup) },
),
editing = editing,
groupKey = group,
onSave = { vm.saveNote(it); adding = false; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDismiss = { adding = false; editing = null },
)
}
}
/** A one-line hint on a lesson card: the first open task or note and how many more. */
@Composable
fun NotesHint(notes: List<Note>, color: androidx.compose.ui.graphics.Color) {
val open = notes.open()
if (open.isEmpty()) return
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Icon(
if (open.first().task) Icons.AutoMirrored.Rounded.Assignment else Icons.Rounded.StickyNote2,
contentDescription = null,
tint = color,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(6.dp))
Text(
open.first().text + if (open.size > 1) " +${open.size - 1}" else "",
style = MaterialTheme.typography.bodyMediumEmphasized,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -117,12 +117,12 @@ fun ScheduleScreen(
val timetable by vm.timetable.collectAsStateWithLifecycle()
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
var details by remember { mutableStateOf<Lesson?>(null) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate?>?>(null) }
val subgroup = settings?.subgroup
val t = timetable
if (t?.semester != null) {
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { details = it })
Planner(vm, nav, t, subgroup, weekView, onWeekView, focus, onFocusHandled, onOpen = { lesson, date -> details = lesson to date })
} else Column(Modifier.fillMaxSize()) {
TopAppBar(
colors = appBarColors(),
@@ -139,12 +139,12 @@ fun ScheduleScreen(
icon = Icons.Rounded.CloudOff,
action = "Выбрать группу" to nav.openGroups,
)
else -> WordRows(t, subgroup, onOpen = { details = it })
else -> WordRows(t, subgroup, onOpen = { details = it to null })
}
}
details?.let { lesson ->
details?.let { (lesson, date) ->
ModalBottomSheet(onDismissRequest = { details = null }) {
LessonDetails(lesson, timetable?.bells.orEmpty())
LessonDetails(lesson, timetable?.bells.orEmpty(), date, vm)
}
}
}
@@ -180,7 +180,7 @@ private fun Planner(
onWeekView: (Boolean) -> Unit,
focus: LocalDate?,
onFocusHandled: () -> Unit,
onOpen: (Lesson) -> Unit,
onOpen: (Lesson, LocalDate) -> Unit,
) {
val status by vm.status.collectAsStateWithLifecycle()
val dates = remember(t) { t.days.map { parseDate(it.date) } }
@@ -337,7 +337,7 @@ private fun Planner(
}
@Composable
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson) -> Unit) {
private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson, LocalDate) -> Unit) {
val clock = rememberClock()
val isToday = date == today()
val next = if (isToday) slots.firstOrNull { it.start.isAfter(clock) } else null
@@ -363,9 +363,10 @@ private fun DayPage(date: LocalDate, slots: List<Slot>, onOpen: (Lesson) -> Unit
lesson,
time = "${slot.start} → ${slot.end}",
pair = lesson.pair,
date = date,
status = status,
running = running,
onClick = { onOpen(lesson) },
onClick = { onOpen(lesson, date) },
)
}
}
@@ -380,6 +381,7 @@ fun LessonCard(
lesson: Lesson,
time: String?,
pair: Int? = null,
date: LocalDate? = null,
status: String? = null,
running: Boolean = false,
onClick: (() -> Unit)?,
@@ -425,6 +427,7 @@ fun LessonCard(
if (lesson.uncertain) Tag("Сверьте недели", warning = true)
}
if (show.notes && lesson.condition.isNotEmpty()) Meta(Icons.Rounded.EventRepeat, "Пометка в Word: ${lesson.condition}")
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
@@ -488,7 +491,7 @@ private fun Tag(text: String, warning: Boolean = false) {
}
@Composable
internal fun LessonDetails(lesson: Lesson, bells: List<Bell>) {
internal fun LessonDetails(lesson: Lesson, bells: List<Bell>, date: LocalDate? = null, vm: MainViewModel? = null) {
val bell = bells.firstOrNull { it.start == lesson.start }
Column(
Modifier
@@ -521,6 +524,7 @@ internal fun LessonDetails(lesson: Lesson, bells: List<Bell>) {
offline = false,
onClose = null,
)
if (date != null && vm != null) LessonNotes(vm, lesson, date)
Heading("Исходная запись", style = MaterialTheme.typography.titleSmallEmphasized)
Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surfaceContainerHigh) {
Text(lesson.raw, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(16.dp).fillMaxWidth())

View File

@@ -2,6 +2,10 @@
package ritm.schedule.ui
import androidx.compose.material.icons.automirrored.rounded.Assignment
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.FilledTonalButton
import ritm.schedule.data.Note
import androidx.compose.foundation.background
import androidx.compose.material.icons.rounded.DateRange
import androidx.compose.material.icons.rounded.Event
@@ -146,7 +150,7 @@ fun SubjectsScreen(vm: MainViewModel, nav: AppNav) {
}
}
open?.let { s ->
ModalBottomSheet(onDismissRequest = { open = null }) { SubjectDetails(s) }
ModalBottomSheet(onDismissRequest = { open = null }) { SubjectDetails(s, vm) }
}
}
@@ -276,6 +280,12 @@ private fun SubjectCard(s: SubjectSummary, onClick: () -> Unit) {
strong = next != null,
)
if (show.teachers && s.teachers.isNotEmpty()) Fact(Icons.Rounded.Person, s.teachers.joinToString(", "))
val tasks = LocalNotes.current.count { it.task && !it.done && subjectKey(it.subject) == subjectKey(s.name) }
if (tasks > 0) Fact(
Icons.AutoMirrored.Rounded.Assignment,
"$tasks ${plural(tasks, "невыполненное задание", "невыполненных задания", "невыполненных заданий")}",
strong = true,
)
Fact(
Icons.Rounded.DateRange,
if (s.weekTotal == 0) "На этой неделе пар нет"
@@ -302,7 +312,7 @@ private fun Fact(icon: ImageVector, text: String, strong: Boolean = false) {
}
@Composable
private fun SubjectDetails(s: SubjectSummary) {
private fun SubjectDetails(s: SubjectSummary, vm: MainViewModel) {
val color = subjectColor(s.name)
Column(
Modifier
@@ -318,6 +328,7 @@ private fun SubjectDetails(s: SubjectSummary) {
Text(s.types.joinToString(" · "), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
DetailLine(Icons.Rounded.Person, "Преподаватели", s.teachers.joinToString(", ").ifEmpty { "Не указаны" })
DetailLine(Icons.Rounded.LocationOn, "Аудитории", s.rooms.joinToString(", ").ifEmpty { "Не указаны" })
SubjectNotes(s, vm)
Heading("Ближайшие занятия", style = MaterialTheme.typography.titleMediumEmphasized)
if (s.upcoming.isEmpty()) Text("В загруженных неделях занятий больше нет.", color = MaterialTheme.colorScheme.onSurfaceVariant)
s.upcoming.take(12).groupBy { it.first.date }.forEach { (date, items) ->
@@ -360,3 +371,36 @@ private fun DetailLine(icon: ImageVector, label: String, value: String) {
}
}
}
/** Every task and note of the subject, open ones first, with a quick add for the next lesson. */
@Composable
private fun SubjectNotes(s: SubjectSummary, vm: MainViewModel) {
val all by vm.notes.collectAsStateWithLifecycle()
val group = vm.currentGroupKey() ?: return
val notes = all.filter { subjectKey(it.subject) == subjectKey(s.name) }
.sortedWith(compareBy({ it.task && it.done }, { it.date }, { it.start }))
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
val next = s.upcoming.firstOrNull()
Heading("Задания и заметки", style = MaterialTheme.typography.titleMediumEmphasized)
if (notes.isEmpty()) Text(
"Пока пусто. Домашнее задание удобно записать прямо из карточки пары.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
notes.forEach { note -> NoteRow(note, onToggle = { vm.toggleNote(note.id) }, onClick = { editing = note }, showSubject = false) }
if (next != null) FilledTonalButton(onClick = { adding = true }) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("К следующему занятию")
}
if (adding || editing != null) NoteEditor(
target = next?.let { (slot, lesson) -> NoteTarget(s.name, slot.date, lesson.start, next = null) },
editing = editing,
groupKey = editing?.group ?: group,
onSave = { vm.saveNote(it); adding = false; editing = null },
onDelete = { vm.deleteNote(it.id); editing = null },
onDismiss = { adding = false; editing = null },
)
}

View File

@@ -63,7 +63,7 @@ fun WeekGrid(
t: Timetable,
subgroup: String?,
monday: LocalDate,
onOpen: (Lesson) -> Unit,
onOpen: (Lesson, LocalDate) -> Unit,
onWeek: (Int) -> Unit,
headerColor: Color,
modifier: Modifier = Modifier,
@@ -156,7 +156,8 @@ fun WeekGrid(
slot.lessons.forEachIndexed { i, lesson ->
LessonBlock(
lesson, slot, wide,
onClick = { onOpen(lesson) },
notes = LocalNotes.current.forLesson(lesson.subject, d, lesson.start).open().size,
onClick = { onOpen(lesson, d) },
modifier = Modifier
.offset(x = width * i, y = y)
.size(width, height)
@@ -176,7 +177,7 @@ fun WeekGrid(
}
@Composable
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, onClick: () -> Unit, modifier: Modifier) {
private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, notes: Int, onClick: () -> Unit, modifier: Modifier) {
val color = subjectColor(lesson.subject)
val room = when {
lesson.distance -> "дист."
@@ -190,9 +191,14 @@ private fun LessonBlock(lesson: Lesson, slot: Slot, wide: Boolean, onClick: () -
contentColor = color.content,
modifier = modifier.semantics {
contentDescription = "${lesson.subject}, ${slot.start}–${slot.end}" +
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "")
(if (room.isNotEmpty()) ", ауд. $room" else "") + (lesson.type.takeIf { it.isNotEmpty() }?.let { ", $it" } ?: "") +
(if (notes > 0) ", заданий и заметок: $notes" else "")
},
) {
// A small dot in the corner marks lessons with open tasks or notes.
if (notes > 0) Box(Modifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.TopEnd) {
Box(Modifier.size(8.dp).background(color.content, CircleShape))
}
Column(Modifier.padding(horizontal = 6.dp, vertical = 4.dp).clearAndSetSemantics {}, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
if (wide) lesson.subject else abbreviate(lesson.subject),

View File

@@ -8,4 +8,7 @@ kotlin.code.style=official
# Адрес API. По умолчанию — сервер из docker-compose на этом компьютере,
# видимый из эмулятора как 10.0.2.2. Для релиза нужен публичный HTTPS-адрес:
# ./gradlew assembleRelease -PPSU_API_URL=https://schedule.example.by/
PSU_API_URL=http://10.0.2.2:3010/
PSU_API_URL=https://psu.rumblur.by/
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true

View File

@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25

View File

@@ -28,11 +28,16 @@ app.use("/api", (_req, res, next) => {
res.set("Cache-Control", "no-store");
next();
});
// Raised whenever an endpoint is added or changed, so a deployment is easy to verify:
// 2 — /api/timetable for the Android client.
const API_VERSION = 2;
// Liveness plus what the server currently reads from the university, without network calls.
app.get("/api/health", async (_req, res) => {
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
res.json({
ok: true,
apiVersion: API_VERSION,
parserVersion: PARSER_VERSION,
...sources,
directory: indexStatus(),