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:
@@ -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}}}
|
||||
@@ -103,6 +103,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
|
||||
- Обзор: отчёт за неделю (сколько пар прошло), следующие занятия сегодня с обратным отсчётом и ближайший учебный день.
|
||||
- Расписание группы по дням с листанием или недельной сеткой по часам; неделя и её цвет, номера пар, текущая пара, детали занятия с исходной записью Word. Если семестр не найден в таблице недель — строки Word по дням недели.
|
||||
- Предметы: пары по дням текущей недели; у каждого предмета — сокращение как в недельной сетке, следующее занятие с аудиторией, преподаватели и сколько пар на этой неделе. У каждого предмета свой постоянный цвет во всех разделах.
|
||||
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
|
||||
- Настройки отображения: цвета предметов, преподаватели, аудитории, номера пар, метки занятий и пометки из Word можно скрыть; полные сведения остаются в карточке занятия.
|
||||
- Группы (боковое меню): поиск по номеру во всём каталоге, недавние, каталог по факультету и курсу, выбор подгруппы.
|
||||
- Поиск по университету: преподаватель или аудитория на день, свободные аудитории на пару.
|
||||
|
||||
BIN
android/app/release/app-release.apk
Normal file
BIN
android/app/release/app-release.apk
Normal file
Binary file not shown.
384295
android/app/release/mapping.txt
Normal file
384295
android/app/release/mapping.txt
Normal file
File diff suppressed because it is too large
Load Diff
37
android/app/release/output-metadata.json
Normal file
37
android/app/release/output-metadata.json
Normal 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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
59
android/app/src/main/kotlin/ritm/schedule/data/Notes.kt
Normal file
59
android/app/src/main/kotlin/ritm/schedule/data/Notes.kt
Normal 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 } }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
294
android/app/src/main/kotlin/ritm/schedule/ui/Notes.kt
Normal file
294
android/app/src/main/kotlin/ritm/schedule/ui/Notes.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
12
android/gradle/gradle-daemon-jvm.properties
Normal file
12
android/gradle/gradle-daemon-jvm.properties
Normal 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
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user