diff --git a/README.md b/README.md
index 664bbee..d43e0dc 100644
--- a/README.md
+++ b/README.md
@@ -263,6 +263,12 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
+- **Уведомления о дне** (настройки → «Уведомления о дне», тоже локальные, из сохранённого расписания):
+ - «Занятия в другом городе» — вечером накануне такого дня (18:00–21:00, по умолчанию 20:00) приходит «Завтра занятия в Полоцке» с первой парой, аудиторией и числом пар. Напоминание о первой паре этого дня, если напоминания включены, приходит раньше обычного на 0–60 минут (по умолчанию 30).
+ - «Переносы аудиторий» — утром в день переноса (6:30–8:00, по умолчанию 7:00, но не позже чем за час до пары) приходит «Сегодня перенос аудитории: 15:20 Возрастная физиология… — ауд. 100, обычно 402Н».
+ - Оба включены по умолчанию. Нажатие открывает этот день в «Расписании».
+- **Текущая пара в шторке** (выключена по умолчанию): пока идёт пара, в шторке висит уведомление — предмет, «до 11:30 · ауд. 205Г · Полоцк», следующая пара и прогресс по половинам пары из расписания звонков. На Android 16 это Live Update: в строке состояния плашка с обратным отсчётом до конца пары, на экране блокировки — само уведомление. Оно появляется по будильнику в начале пары, обновляется раз в несколько минут и само исчезает в конце.
+- **Ярлыки на иконке** (долгое нажатие): «Пары на сегодня», «Неделя», «Найти аудиторию», «Новое задание» (открывает «Планы» с выбором пары).
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4c05ef1..19779c2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,8 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt
new file mode 100644
index 0000000..6875a88
--- /dev/null
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt
@@ -0,0 +1,49 @@
+package org.xtimms.psu.schedule
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import java.time.LocalDate
+
+/**
+ * Where the app opens from outside: a launcher shortcut (res/xml/shortcuts.xml) or a
+ * notification. The intent carries a target and, for a day, its date.
+ */
+sealed interface Launch {
+ data object Today : Launch
+ data object Week : Launch
+ data object Search : Launch
+ data object NewTask : Launch
+ data class Day(val date: LocalDate) : Launch
+
+ companion object {
+ const val ACTION = "org.xtimms.psu.schedule.OPEN"
+ private const val TARGET = "target"
+ private const val DATE = "date"
+
+ fun of(intent: Intent?): Launch? {
+ if (intent?.action != ACTION) return null
+ return when (intent.getStringExtra(TARGET)) {
+ "today" -> Today
+ "week" -> Week
+ "search" -> Search
+ "task" -> NewTask
+ "day" -> intent.getStringExtra(DATE)?.let { runCatching { Day(LocalDate.parse(it)) }.getOrNull() }
+ else -> null
+ }
+ }
+
+ /** Opens a day of the timetable, for a notification about it. */
+ fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
+ PendingIntent.getActivity(
+ context,
+ request,
+ Intent(context, MainActivity::class.java)
+ .setAction(ACTION)
+ .putExtra(TARGET, "day")
+ .putExtra(DATE, date.toString())
+ .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt
index c390af8..7077e62 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt
@@ -150,9 +150,19 @@ private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels()
+ /** A launcher shortcut or a notification asked for a screen (see Launch.kt). */
+ private val launch = androidx.compose.runtime.mutableStateOf(null)
+
+ override fun onNewIntent(intent: android.content.Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ Launch.of(intent)?.let { launch.value = it }
+ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ // A recreated activity (rotation) must not open the same screen again.
+ if (savedInstanceState == null) launch.value = Launch.of(intent)
enableEdgeToEdge()
setContent {
val settings by vm.settings.collectAsStateWithLifecycle()
@@ -181,7 +191,7 @@ class MainActivity : ComponentActivity() {
label = "onboarding",
) { first ->
if (first) Onboarding(vm, onDone = { onboarding = false })
- else App(vm, firstRun = s.current == null)
+ else App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
}
}
}
@@ -199,7 +209,7 @@ private val EXPANDED_WIDTH = 840.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
-private fun App(vm: MainViewModel, firstRun: Boolean) {
+private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunched: () -> Unit) {
var tab by rememberSaveable { mutableStateOf(Tab.Home) }
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
// Unset until the student picks: wide windows open the schedule as a week grid.
@@ -273,6 +283,23 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
openMenu = if (wide) null else ({ menuOpen = true }),
)
}
+ // A shortcut or a notification: its screen, once the student has a group.
+ LaunchedEffect(launch, hasGroup) {
+ if (launch == null || !hasGroup) return@LaunchedEffect
+ page = null
+ menuOpen = false
+ when (launch) {
+ Launch.Today -> nav.openDay(today())
+ Launch.Week -> nav.openWeek()
+ Launch.Search -> nav.openSearch()
+ Launch.NewTask -> {
+ nav.openPlans()
+ vm.newTask.value = true
+ }
+ is Launch.Day -> nav.openDay(launch.date)
+ }
+ onLaunched()
+ }
val menuItems = MenuItems(
tab = tab,
page = page,
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt
index b25c8ed..8ce089c 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt
@@ -27,6 +27,8 @@ class ScheduleApp : Application() {
attendance = AttendanceStore(this)
Reminders.createChannel(this)
org.xtimms.psu.schedule.notify.ChangeAlerts.createChannel(this)
+ org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
+ org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this)
SyncWorker.schedule(this)
}
}
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt
index 27117a2..9bf392a 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt
@@ -43,6 +43,19 @@ data class UserSettings(
val reminderMinutes: Int = 10,
/** A notification when the background sync finds changes in the group's document. */
val changeAlerts: Boolean = true,
+ /**
+ * "Завтра занятия в Полоцке": a notification the evening before a day in another town, at
+ * [travelTime] (minutes after midnight), and the reminder of that day's first lesson
+ * [travelLead] minutes earlier than usual.
+ */
+ val travelAlerts: Boolean = true,
+ val travelTime: Int = 20 * 60,
+ val travelLead: Int = 30,
+ /** A notification on the morning of a day with a room move, at [roomTime]. */
+ val roomAlerts: Boolean = true,
+ val roomTime: Int = 7 * 60,
+ /** The lesson under way as an ongoing notification (a Live Update on Android 16). */
+ val liveLesson: Boolean = false,
val recent: List = emptyList(),
val display: Display = Display(),
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
@@ -62,6 +75,12 @@ class Settings(private val context: Context) {
val reminders = booleanPreferencesKey("reminders")
val minutes = intPreferencesKey("reminder_minutes")
val changeAlerts = booleanPreferencesKey("change_alerts")
+ val travelAlerts = booleanPreferencesKey("travel_alerts")
+ val travelTime = intPreferencesKey("travel_time")
+ val travelLead = intPreferencesKey("travel_lead")
+ val roomAlerts = booleanPreferencesKey("room_alerts")
+ val roomTime = intPreferencesKey("room_time")
+ val liveLesson = booleanPreferencesKey("live_lesson")
val recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers")
@@ -95,6 +114,12 @@ class Settings(private val context: Context) {
reminders = this[Keys.reminders] ?: false,
reminderMinutes = this[Keys.minutes] ?: 10,
changeAlerts = this[Keys.changeAlerts] ?: true,
+ travelAlerts = this[Keys.travelAlerts] ?: true,
+ travelTime = this[Keys.travelTime] ?: 20 * 60,
+ travelLead = this[Keys.travelLead] ?: 30,
+ roomAlerts = this[Keys.roomAlerts] ?: true,
+ roomTime = this[Keys.roomTime] ?: 7 * 60,
+ liveLesson = this[Keys.liveLesson] ?: false,
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString>(it) }.getOrNull()
}.orEmpty(),
@@ -149,6 +174,12 @@ class Settings(private val context: Context) {
suspend fun setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
suspend fun setChangeAlerts(on: Boolean) = context.store.edit { it[Keys.changeAlerts] = on }
+ suspend fun setTravelAlerts(on: Boolean) = context.store.edit { it[Keys.travelAlerts] = on }
+ suspend fun setTravelTime(minutes: Int) = context.store.edit { it[Keys.travelTime] = minutes }
+ suspend fun setTravelLead(minutes: Int) = context.store.edit { it[Keys.travelLead] = minutes }
+ suspend fun setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
+ suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes }
+ suspend fun setLiveLesson(on: Boolean) = context.store.edit { it[Keys.liveLesson] = on }
/** Hides or shows again a recurring lesson of the open group. */
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt
new file mode 100644
index 0000000..b4a05c2
--- /dev/null
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt
@@ -0,0 +1,143 @@
+package org.xtimms.psu.schedule.notify
+
+import android.app.AlarmManager
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import org.xtimms.psu.schedule.Launch
+import org.xtimms.psu.schedule.R
+import org.xtimms.psu.schedule.data.Lesson
+import org.xtimms.psu.schedule.data.MINSK
+import org.xtimms.psu.schedule.data.Repository
+import org.xtimms.psu.schedule.data.Timetable
+import org.xtimms.psu.schedule.data.UserSettings
+import org.xtimms.psu.schedule.data.parseDate
+import org.xtimms.psu.schedule.data.parseTime
+import org.xtimms.psu.schedule.data.plural
+import org.xtimms.psu.schedule.data.today
+import org.xtimms.psu.schedule.data.withPlace
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+
+/**
+ * What the day has in store, told in advance, from the cached timetable like the reminders:
+ * "Завтра занятия в Полоцке" the evening before a day in another town, and the morning's room
+ * moves ("10:05 Типология — ауд. 333, обычно 412Н"). Each has its switch and time in settings.
+ */
+object DayAlerts {
+ private const val CHANNEL = "day_alerts"
+ private const val ACTION = "org.xtimms.psu.schedule.DAY_ALERT"
+ // Request codes: a week of travel evenings, then a week of room-move mornings.
+ private const val TRAVEL = 0
+ private const val ROOMS = 16
+ private const val MAX = 16
+
+ fun createChannel(context: Context) {
+ val channel = NotificationChannel(
+ CHANNEL,
+ context.getString(R.string.channel_day),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply { description = "Накануне дня в другом городе и утром в день переноса аудитории" }
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ private fun intent(context: Context, code: Int, extras: Intent.() -> Unit = {}): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ code,
+ Intent(context, DayAlertReceiver::class.java).setAction(ACTION).apply(extras),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private fun rooms(l: Lesson) = l.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
+
+ /** Replaces the scheduled alerts with the ones of the coming week. */
+ fun reschedule(context: Context, timetable: Timetable?, settings: UserSettings, repository: Repository) {
+ val alarms = context.getSystemService(AlarmManager::class.java)
+ repeat(ROOMS + MAX) { alarms.cancel(intent(context, it)) }
+ if (timetable == null) return
+ val now = Instant.now()
+ val days = (0..7L).map { today().plusDays(it) }
+ var travel = 0
+ var moves = 0
+ for (date in days) {
+ val lessons = repository.lessonsOn(timetable, date, settings.subgroup, settings.hidden).sortedBy { it.start }
+ val first = lessons.firstOrNull() ?: continue
+ val places = repository.placesOn(timetable, date, settings.subgroup, settings.hidden)
+ if (settings.travelAlerts && places.isNotEmpty() && travel < MAX) {
+ val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
+ if (at.isAfter(now)) {
+ val note = places.first()
+ val title = "Завтра " + note.text.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
+ val text = "Первая пара в ${first.start} — ${first.subject}" +
+ (withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
+ ". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}."
+ Reminders.alarm(context, at.toEpochMilli(), intent(context, TRAVEL + travel) {
+ putExtra("id", TRAVEL + travel)
+ putExtra("title", title)
+ putExtra("text", text)
+ putExtra("date", date.toString())
+ })
+ travel++
+ }
+ }
+ val moved = lessons.filter { it.roomMoved }
+ if (settings.roomAlerts && moved.isNotEmpty() && moves < MAX) {
+ // The chosen morning time, or an hour before the first moved lesson if that is earlier.
+ val firstMoved = date.atTime(parseTime(moved.first().start))
+ val morning = date.atTime(LocalTime.ofSecondOfDay(settings.roomTime * 60L))
+ val at = minOf(morning, firstMoved.minusHours(1)).atZone(MINSK).toInstant()
+ if (at.isAfter(now)) {
+ // The usual room comes from the Word row of the weekday: same lesson key.
+ val usual = timetable.weekly.flatMap { it.lessons }.associateBy { it.key }
+ val lines = moved.map { l ->
+ val before = usual[l.key]?.rooms?.joinToString(", ")?.takeIf { it.isNotEmpty() && it != l.rooms.joinToString(", ") }
+ "${l.start} ${l.subject} — ${rooms(l) ?: "аудитория не указана"}" + (before?.let { ", обычно $it" } ?: "")
+ }
+ // It comes on the day itself.
+ val title = if (moved.size == 1) "Сегодня перенос аудитории" else "Сегодня переносов аудиторий: ${moved.size}"
+ Reminders.alarm(context, at.toEpochMilli(), intent(context, ROOMS + moves) {
+ putExtra("id", ROOMS + moves)
+ putExtra("title", title)
+ putExtra("text", lines.joinToString("\n"))
+ putExtra("date", date.toString())
+ })
+ moves++
+ }
+ }
+ }
+ }
+
+ fun show(context: Context, id: Int, title: String, text: String, date: LocalDate) {
+ if (!Reminders.canNotify(context)) return
+ val notification = NotificationCompat.Builder(context, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setContentText(text.lineSequence().first())
+ .setStyle(NotificationCompat.BigTextStyle().bigText(text))
+ .setCategory(NotificationCompat.CATEGORY_REMINDER)
+ .setContentIntent(Launch.day(context, date, 2000 + id))
+ .setAutoCancel(true)
+ .build()
+ @Suppress("MissingPermission")
+ NotificationManagerCompat.from(context).notify(2000 + id, notification)
+ }
+}
+
+class DayAlertReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ DayAlerts.show(
+ context,
+ intent.getIntExtra("id", 0),
+ intent.getStringExtra("title") ?: return,
+ intent.getStringExtra("text").orEmpty(),
+ intent.getStringExtra("date")?.let(::parseDate) ?: today(),
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt
new file mode 100644
index 0000000..7dea042
--- /dev/null
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt
@@ -0,0 +1,164 @@
+package org.xtimms.psu.schedule.notify
+
+import android.app.AlarmManager
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.xtimms.psu.schedule.Launch
+import org.xtimms.psu.schedule.R
+import org.xtimms.psu.schedule.app
+import org.xtimms.psu.schedule.data.Lesson
+import org.xtimms.psu.schedule.data.MINSK
+import org.xtimms.psu.schedule.data.Repository
+import org.xtimms.psu.schedule.data.Timetable
+import org.xtimms.psu.schedule.data.UserSettings
+import org.xtimms.psu.schedule.data.nowDateTime
+import org.xtimms.psu.schedule.data.parseTime
+import org.xtimms.psu.schedule.data.today
+import org.xtimms.psu.schedule.data.withPlace
+import org.xtimms.psu.schedule.ui.lessonEnd
+import java.time.Duration
+import java.time.LocalDate
+import java.time.LocalDateTime
+
+/**
+ * The lesson under way as an ongoing notification: the subject, the room, how long is left
+ * and the next lesson. On Android 16 it is a Live Update (a promoted ongoing notification with
+ * a progress bar in the pair's two halves) in the status bar and on the lock screen. An alarm
+ * at each lesson's start shows it; while the lesson runs it refreshes every few minutes and it
+ * leaves by itself at the end. Off by default ("Текущая пара в шторке").
+ */
+object LiveLesson {
+ private const val CHANNEL = "lesson_now"
+ private const val ID = 3000
+ private const val ACTION = "org.xtimms.psu.schedule.LIVE_LESSON"
+ private const val MAX = 48
+ // The refresh while a lesson runs, after the start alarms' request codes.
+ private const val TICK = MAX
+ private const val EVERY = 5 * 60 * 1000L
+
+ fun createChannel(context: Context) {
+ val channel = NotificationChannel(
+ CHANNEL,
+ context.getString(R.string.channel_now),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = "Идущая пара: сколько осталось и что дальше"
+ setSound(null, null)
+ enableVibration(false)
+ }
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
+ }
+
+ private fun intent(context: Context, code: Int): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ code,
+ Intent(context, LiveLessonReceiver::class.java).setAction(ACTION),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ private class Slot(val start: LocalDateTime, val end: LocalDateTime, val lessons: List)
+
+ private fun slots(t: Timetable, date: LocalDate, s: UserSettings, repository: Repository) =
+ repository.lessonsOn(t, date, s.subgroup, s.hidden)
+ .groupBy { it.start }
+ .map { (start, lessons) -> Slot(date.atTime(parseTime(start)), date.atTime(lessonEnd(lessons.first(), t)), lessons) }
+ .sortedBy { it.start }
+
+ /** Alarms at the starts of the coming lessons, and the lesson under way shown now. */
+ fun reschedule(context: Context, t: Timetable?, s: UserSettings, repository: Repository) {
+ val alarms = context.getSystemService(AlarmManager::class.java)
+ repeat(MAX + 1) { alarms.cancel(intent(context, it)) }
+ if (!s.liveLesson || t == null) {
+ NotificationManagerCompat.from(context).cancel(ID)
+ return
+ }
+ val now = nowDateTime()
+ var code = 0
+ for (offset in 0..2L) for (slot in slots(t, today().plusDays(offset), s, repository)) {
+ if (code >= MAX) break
+ if (slot.start.isAfter(now)) Reminders.alarm(context, slot.start.atZone(MINSK).toInstant().toEpochMilli(), intent(context, code++))
+ }
+ update(context, t, s, repository)
+ }
+
+ /** Shows, refreshes or removes the notification for this moment. */
+ fun update(context: Context, t: Timetable?, s: UserSettings, repository: Repository) {
+ val manager = NotificationManagerCompat.from(context)
+ val now = nowDateTime()
+ val day = if (t != null && s.liveLesson) slots(t, today(), s, repository) else emptyList()
+ val slot = day.firstOrNull { !now.isBefore(it.start) && now.isBefore(it.end) }
+ if (slot == null || !Reminders.canNotify(context) || t == null) {
+ manager.cancel(ID)
+ return
+ }
+ val next = day.firstOrNull { it.start >= slot.end }
+ val first = slot.lessons.first()
+ val total = Duration.between(slot.start, slot.end).toMinutes().toInt().coerceAtLeast(1)
+ val passed = Duration.between(slot.start, now).toMinutes().toInt().coerceIn(0, total)
+ val room = withPlace(
+ slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
+ slot.lessons.firstNotNullOfOrNull { it.place },
+ )
+ // The pair's halves from the bell table ("08:30–09:10, 09:15–09:55"): one segment each.
+ val halves = t.bells.firstOrNull { it.start == first.start }?.halves.orEmpty()
+ .mapNotNull { h -> runCatching { Duration.between(slot.start.toLocalTime(), parseTime(h[1])).toMinutes().toInt() }.getOrNull() }
+ .filter { it in 1 until total }
+ val progress = NotificationCompat.ProgressStyle()
+ .setStyledByProgress(true)
+ .setProgressSegments(
+ (listOf(0) + halves + total).zipWithNext { a, b -> b - a }
+ .filter { it > 0 }
+ .map { NotificationCompat.ProgressStyle.Segment(it) },
+ )
+ .setProgress(passed)
+ val text = listOfNotNull("до ${slot.end.toLocalTime()}", room).joinToString(" · ")
+ val notification = NotificationCompat.Builder(context, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(slot.lessons.joinToString(" / ") { it.subject })
+ .setContentText(text)
+ .setSubText(next?.let { "Дальше в ${it.start.toLocalTime()} — ${it.lessons.first().subject}" })
+ .setStyle(progress)
+ // The status bar chip counts down to the end by itself (a short text would replace it).
+ .setUsesChronometer(true)
+ .setChronometerCountDown(true)
+ .setWhen(slot.end.atZone(MINSK).toInstant().toEpochMilli())
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setRequestPromotedOngoing(true)
+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+ .setTimeoutAfter(Duration.between(now, slot.end).toMillis())
+ .setContentIntent(Launch.day(context, today(), ID))
+ .build()
+ @Suppress("MissingPermission")
+ manager.notify(ID, notification)
+ // The next refresh: in a few minutes, or at the end to take the notification away.
+ val nextTick = minOf(now.plusNanos(EVERY * 1_000_000), slot.end).atZone(MINSK).toInstant().toEpochMilli()
+ context.getSystemService(AlarmManager::class.java)
+ .setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTick, intent(context, TICK))
+ }
+}
+
+class LiveLessonReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val pending = goAsync()
+ CoroutineScope(Dispatchers.Default).launch {
+ try {
+ val app = context.app
+ LiveLesson.update(context, app.repository.timetable.value, app.settings.now(), app.repository)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt
index 15b1ba2..19368e5 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt
@@ -2,6 +2,7 @@ package org.xtimms.psu.schedule.notify
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.withPlace
+import org.xtimms.psu.schedule.data.forSubgroup
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
@@ -76,13 +77,17 @@ object Reminders {
var slot = 0
for (day in timetable.days) {
val date = parseDate(day.date)
- // One reminder per start time: parallel subgroups share the notification.
- for ((start, lessons) in day.lessons
+ val shown = day.lessons
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
.filter { it.hideKey !in settings.hidden }
- .groupBy { it.start }) {
+ // A day in another town: the first lesson is announced earlier, for the road.
+ val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty()
+ val first = shown.minOfOrNull { it.start }
+ // One reminder per start time: parallel subgroups share the notification.
+ for ((start, lessons) in shown.groupBy { it.start }) {
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
- val at = begins.minusSeconds(settings.reminderMinutes * 60L)
+ val lead = settings.reminderMinutes + if (away && start == first) settings.travelLead else 0
+ val at = begins.minusSeconds(lead * 60L)
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 }
@@ -101,18 +106,24 @@ object Reminders {
putExtra("text", text)
putExtra("id", slot)
}
- if (canBeExact(context)) {
- alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at.toEpochMilli(), pending)
- } else {
- // Without exact alarms Android may deliver up to an hour late; a
- // 10-minute window (the shortest allowed) centred on the time keeps it useful.
- alarms.setWindow(AlarmManager.RTC_WAKEUP, at.toEpochMilli() - WINDOW / 2, WINDOW, pending)
- }
+ alarm(context, at.toEpochMilli(), pending)
slot++
}
}
}
+ /** An alarm at [at], exact when the student allowed it. */
+ fun alarm(context: Context, at: Long, pending: PendingIntent) {
+ val alarms = context.getSystemService(AlarmManager::class.java)
+ if (canBeExact(context)) {
+ alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending)
+ } else {
+ // Without exact alarms Android may deliver up to an hour late; a
+ // 10-minute window (the shortest allowed) centred on the time keeps it useful.
+ alarms.setWindow(AlarmManager.RTC_WAKEUP, at - WINDOW / 2, WINDOW, pending)
+ }
+ }
+
fun show(context: Context, id: Int, title: String, text: String) {
if (!canNotify(context)) return
val open = PendingIntent.getActivity(
@@ -151,8 +162,8 @@ class BootReceiver : BroadcastReceiver() {
val pending = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
- val app = context.app
- Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
+ // Reminders, day alerts and the live lesson, as after any change.
+ org.xtimms.psu.schedule.work.SyncWorker.afterChange(context)
} finally {
pending.finish()
}
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt
index 06bc099..8c4ad61 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt
@@ -161,6 +161,8 @@ fun ConnectedChoice(
selected: T,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
+ /** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
+ mark: Boolean = true,
) {
Row(
modifier.selectableGroup(),
@@ -185,7 +187,7 @@ fun ConnectedChoice(
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
) {
- if (checked) {
+ if (checked && mark) {
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
}
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt
index d5c8fb7..872b599 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt
@@ -166,6 +166,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
+ /** "Новое задание" from the launcher: the plans open with the add sheet. */
+ val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)
+
+ /** Day alerts and the live lesson are alarms too: set them again after every change. */
+ private fun alerts(change: suspend () -> Unit) = viewModelScope.launch {
+ change()
+ SyncWorker.afterChange(getApplication())
+ }
+ fun setTravelAlerts(on: Boolean) = alerts { app.settings.setTravelAlerts(on) }
+ fun setTravelTime(minutes: Int) = alerts { app.settings.setTravelTime(minutes) }
+ fun setTravelLead(minutes: Int) = alerts { app.settings.setTravelLead(minutes) }
+ fun setRoomAlerts(on: Boolean) = alerts { app.settings.setRoomAlerts(on) }
+ fun setRoomTime(minutes: Int) = alerts { app.settings.setRoomTime(minutes) }
+ fun setLiveLesson(on: Boolean) = alerts { app.settings.setLiveLesson(on) }
+
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
app.settings.setReminderMinutes(minutes)
SyncWorker.afterChange(getApplication())
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt
index 7c0f4a8..3f8dbaa 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt
@@ -125,6 +125,13 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val prefs = settings?.plans ?: PlansPrefs()
var editing by remember { mutableStateOf(null) }
var adding by remember { mutableStateOf(false) }
+ val wantsTask by vm.newTask.collectAsStateWithLifecycle()
+ LaunchedEffect(wantsTask) {
+ if (wantsTask) {
+ adding = true
+ vm.newTask.value = false
+ }
+ }
var tuning by remember { mutableStateOf(false) }
var details by remember { mutableStateOf?>(null) }
var target by remember { mutableStateOf(null) }
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt
index 691a768..615de3a 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt
@@ -60,6 +60,20 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setChangeAlerts(granted)
}
+ // The day's alerts ask for the same permission; the switch that asked gets the answer.
+ var afterGrant by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<((Boolean) -> Unit)?>(null) }
+ val dayPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ afterGrant?.invoke(granted)
+ afterGrant = null
+ }
+ /** A switch that needs notifications: asks first on Android 13+, then sets the setting. */
+ fun notifying(set: (Boolean) -> Unit): (Boolean) -> Unit = { on ->
+ if (on && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context)) {
+ afterGrant = set
+ dayPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else set(on)
+ }
+ val allowed = Reminders.canNotify(context)
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
@@ -170,6 +184,56 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
)
}
}
+ Section("Уведомления о дне") {
+ SwitchRow(
+ "Занятия в другом городе",
+ "Вечером накануне — «Завтра занятия в Полоцке» с первой парой и аудиторией. Если включены напоминания, о первой паре такого дня напомнит раньше: нужно время на дорогу.",
+ s.travelAlerts && allowed,
+ notifying(vm::setTravelAlerts),
+ )
+ if (s.travelAlerts && allowed) {
+ Text("Вечером в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ ConnectedChoice(
+ options = listOf(18 * 60 to "18:00", 19 * 60 to "19:00", 20 * 60 to "20:00", 21 * 60 to "21:00"),
+ selected = s.travelTime,
+ onSelect = vm::setTravelTime,
+ modifier = Modifier.fillMaxWidth(),
+ mark = false,
+ )
+ if (s.reminders) {
+ Text("О первой паре — раньше на, мин", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ ConnectedChoice(
+ options = listOf(0 to "0", 15 to "15", 30 to "30", 45 to "45", 60 to "60"),
+ selected = s.travelLead,
+ onSelect = vm::setTravelLead,
+ modifier = Modifier.fillMaxWidth(),
+ mark = false,
+ )
+ }
+ }
+ SwitchRow(
+ "Переносы аудиторий",
+ "Утром в день переноса — какая пара и в какой аудитории, вместо обычной.",
+ s.roomAlerts && allowed,
+ notifying(vm::setRoomAlerts),
+ )
+ if (s.roomAlerts && allowed) {
+ Text("Утром в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ ConnectedChoice(
+ options = listOf(6 * 60 + 30 to "6:30", 7 * 60 to "7:00", 7 * 60 + 30 to "7:30", 8 * 60 to "8:00"),
+ selected = s.roomTime,
+ onSelect = vm::setRoomTime,
+ modifier = Modifier.fillMaxWidth(),
+ mark = false,
+ )
+ }
+ SwitchRow(
+ "Текущая пара в шторке",
+ "Пока идёт пара — уведомление с оставшимся временем и следующей парой. На Android 16 оно видно в строке состояния и на экране блокировки.",
+ s.liveLesson && allowed,
+ notifying(vm::setLiveLesson),
+ )
+ }
Section("О расписании") {
val doc = timetable?.document
Info("Документ", doc?.name ?: "Группа не выбрана")
diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt
index eead016..be9e835 100644
--- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt
+++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt
@@ -13,6 +13,8 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import org.xtimms.psu.schedule.app
import org.xtimms.psu.schedule.notify.ChangeAlerts
+import org.xtimms.psu.schedule.notify.DayAlerts
+import org.xtimms.psu.schedule.notify.LiveLesson
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget
import java.util.concurrent.TimeUnit
@@ -52,7 +54,11 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
/** Widget and reminders follow the cached timetable and settings. */
suspend fun afterChange(context: Context) {
val app = context.app
- Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
+ val t = app.repository.timetable.value
+ val s = app.settings.now()
+ Reminders.reschedule(context, t, s)
+ DayAlerts.reschedule(context, t, s, app.repository)
+ LiveLesson.reschedule(context, t, s, app.repository)
TodayWidget().updateAll(context)
NextLessonWidget().updateAll(context)
LessonTileService.refresh(context)
diff --git a/android/app/src/main/res/drawable/ic_shortcut_search.xml b/android/app/src/main/res/drawable/ic_shortcut_search.xml
new file mode 100644
index 0000000..6058180
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_shortcut_search.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_shortcut_task.xml b/android/app/src/main/res/drawable/ic_shortcut_task.xml
new file mode 100644
index 0000000..db794cb
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_shortcut_task.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_shortcut_today.xml b/android/app/src/main/res/drawable/ic_shortcut_today.xml
new file mode 100644
index 0000000..fb5cf19
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_shortcut_today.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_shortcut_week.xml b/android/app/src/main/res/drawable/ic_shortcut_week.xml
new file mode 100644
index 0000000..ebcd4d4
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_shortcut_week.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e5a719c..b4a9bc9 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -5,6 +5,16 @@
Текущая и следующая пара вашей группы
Перед парой
Изменения в расписании
+ Поездки и переносы
+ Текущая пара
+ Сегодня
+ Пары на сегодня
+ Неделя
+ Вся неделя
+ Найти аудиторию
+ Найти аудиторию
+ Новое задание
+ Новое задание
Следующая пара
Когда следующая пара, что это и в какой аудитории
Пара
diff --git a/android/app/src/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..9d0b2e9
--- /dev/null
+++ b/android/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+