feat: add change alerts for schedule updates with notifications

This commit is contained in:
2026-10-20 00:29:42 +03:00
parent f8984e72d9
commit 9b5062f0ed
13 changed files with 179 additions and 4 deletions

View File

@@ -157,7 +157,7 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- Планы: задания и заметки по дням — к занятию или общие, без привязки к паре, на выбранный день, просроченные сверху, «+» прикрепляет задание к ближайшему занятию. Три макета, как в School Planner: «Повестка дня» (все дни с липкими заголовками и полосой недели, которая следует за списком), «Список» (только дни с делами) и «Доска» (колонки дней). В настройках вида — пары дня и выполненные задания.
- Календарь: месяц с отметками дней с парами и с планами, зелёные недели подсвечены; под ним лист с планами и парами выбранного дня. Лист можно поднять — месяц сворачивается до выбранной недели.
- Обратный отсчёт до сессии в «Обзоре»: дни до ближайшего экзамена или зачёта из `/api/exams` (консультации — только в списке), по нажатию — вся сессия. Пока университет не опубликовал сессию, отсчёт идёт до конца семестра. Экзамены кэшируются на устройстве.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- **Изменения в расписании.** Как в PWA: когда университет перезаливает документ группы, приложение сравнивает новое расписание с копией на устройстве. В «Обзоре» появляется карточка «Расписание обновлено · N изменений» с кнопкой «Показать», а в списке видно, что изменилось, что добавлено и что удалено, например «Аудитория: 374 → 375». Карточка показывается до просмотра или закрытия, последний список остаётся в настройках («Последние изменения»). Сравнение срабатывает и при фоновом обновлении (`SyncWorker`, раз в 6 часов). Если изменения нашлись в фоне, приходит уведомление «Расписание 23-АРХ изменилось» с первыми изменениями, например «Проектирование общественных зданий: аудитория 374 → 375». Уведомление локальное, сервер push для него не нужен. Его можно выключить в настройках («Изменения в расписании», по умолчанию включено), а на Android 13+ нужно разрешение на уведомления. Считается только новая версия того же файла: смена версии парсера или переход на другой документ изменениями не считаются. Сравниваются строки Word по дням недели и разовые занятия со своей датой, а не каждая рассчитанная дата.
- Задания и заметки к паре: из карточки занятия — к этой паре или к следующей паре того же предмета. Задания отмечаются выполненными, просроченные подсвечиваются; список — в «Обзоре», по предмету — в «Предметах», точка на блоке в недельной сетке. Хранятся только на устройстве (`notes.json`) и привязаны к группе.
- Посещаемость: «был» или «пропустил» в карточке занятия и одним касанием в «Обзоре» для прошедших сегодня пар; статистика по каждому предмету в «Предметах». Хранится на устройстве (`attendance.json`).
- Скрытие занятий: «Скрыть такие занятия» в карточке занятия убирает предмет с этим типом занятия из расписания, обзора, виджетов и напоминаний; вернуть можно в настройках.
@@ -192,7 +192,7 @@ cd android
- **Онбординг:** приветствие (три стеклянные «капли» сливаются в одну), затем группа, подгруппа (только если группа делится на подгруппы) и напоминания. Разрешение на уведомления спрашивается на последнем шаге.
- **Стеклянная панель вкладок:** три раздела — «Обзор», «Расписание», «Планы» — и поиск отдельной круглой кнопкой справа (роль `.search`). Нажатие превращает кнопку в поле ввода над клавиатурой, как в приложениях Apple (`tabViewSearchActivation(.searchTabSelection)`). «Календарь» открывается кнопкой в «Планах», «Предметы» — карточкой в «Обзоре». На iPad панель превращается в боковое меню. Группа и настройки открываются из панели «Обзора»; тема меняется сразу во всех окнах, включая открытые настройки.
- **Обзор:** неделя (сколько пар осталось, чип на каждый день), обратный отсчёт до сессии или конца семестра, следующие занятия с отсчётом «Через 20 мин», посещаемость одним касанием, задания и ближайший учебный день. На iPad рядом показывается недельная сетка.
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении.
- **Изменения в расписании:** как в Android и PWA — карточка «Расписание обновлено · N изменений» в «Обзоре» после перезаливки документа, по «Показать» — лист с изменёнными, добавленными и удалёнными занятиями; последний список — в настройках. Сравнение срабатывает и при фоновом обновлении `BGAppRefreshTask`: тогда приходит локальное уведомление (настройка «Изменения в расписании»). Когда запускать фоновое обновление, решает iOS, поэтому уведомление может прийти через несколько часов, а если приложение смахнули из переключателя — только после следующего открытия.
- **Расписание:** дни листаются в сторону под плавающей стеклянной лентой недели. На зелёных неделях стекло окрашено зелёным. Есть недельная сетка по часам и строки Word, если семестр не найден. В подробностях пары: время по звонкам, посещаемость, задания, исходная запись Word и скрытие таких занятий.
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.

View File

@@ -26,6 +26,7 @@ class ScheduleApp : Application() {
notes = NotesStore(this)
attendance = AttendanceStore(this)
Reminders.createChannel(this)
org.xtimms.psu.schedule.notify.ChangeAlerts.createChannel(this)
SyncWorker.schedule(this)
}
}

View File

@@ -41,6 +41,8 @@ data class UserSettings(
val dynamicColor: Boolean = false,
val reminders: Boolean = false,
val reminderMinutes: Int = 10,
/** A notification when the background sync finds changes in the group's document. */
val changeAlerts: Boolean = true,
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
@@ -59,6 +61,7 @@ class Settings(private val context: Context) {
val dynamic = booleanPreferencesKey("dynamic_color")
val reminders = booleanPreferencesKey("reminders")
val minutes = intPreferencesKey("reminder_minutes")
val changeAlerts = booleanPreferencesKey("change_alerts")
val recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers")
@@ -91,6 +94,7 @@ class Settings(private val context: Context) {
dynamicColor = this[Keys.dynamic] ?: false,
reminders = this[Keys.reminders] ?: false,
reminderMinutes = this[Keys.minutes] ?: 10,
changeAlerts = this[Keys.changeAlerts] ?: true,
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(),
@@ -136,6 +140,7 @@ class Settings(private val context: Context) {
suspend fun setDynamicColor(on: Boolean) = context.store.edit { it[Keys.dynamic] = on }
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 }
/** Hides or shows again a recurring lesson of the open group. */
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->

View File

@@ -0,0 +1,68 @@
package org.xtimms.psu.schedule.notify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.xtimms.psu.schedule.MainActivity
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.data.ScheduleChanges
import org.xtimms.psu.schedule.data.dayName
import org.xtimms.psu.schedule.data.longLabel
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.ui.changeDate
import org.xtimms.psu.schedule.ui.changesLabel
/**
* A notification when the background sync finds that the university republished the group's
* document and something changed. Local, like the reminders: the sync itself notices it, every
* few hours, without a push server. Opening it shows the overview with the changes card.
*/
object ChangeAlerts {
private const val CHANNEL = "schedule_changes"
private const val ID = 900
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL,
context.getString(R.string.channel_changes),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Когда университет перезалил документ группы и в расписании что-то поменялось" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
/** "Проектирование общественных зданий: аудитория 374 → 375", up to [max] lines. */
fun summary(record: ScheduleChanges, max: Int = 4): List<String> {
val lines = record.changed.map { c ->
c.after.lesson.subject + ": " + c.fields.joinToString(", ") { "${it.label.lowercase()} ${it.from} → ${it.to}" }
.ifEmpty { "изменилась запись Word" }
} + record.added.map { "Добавлено: ${it.lesson.subject}, ${it.date?.let { d -> parseDate(d).longLabel() } ?: dayName(it.day).lowercase()} ${it.lesson.start}" } +
record.removed.map { "Удалено: ${it.lesson.subject}" }
return if (lines.size > max) lines.take(max - 1) + "И ещё ${lines.size - max + 1}" else lines
}
fun show(context: Context, record: ScheduleChanges) {
if (!Reminders.canNotify(context)) return
val open = PendingIntent.getActivity(
context,
1,
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val text = changesLabel(record.count) + (changeDate(record.to)?.let { " · файл от $it" } ?: "")
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Расписание ${record.group} изменилось")
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText((listOf(text) + summary(record)).joinToString("\n")))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(open)
.setAutoCancel(true)
.build()
@Suppress("MissingPermission")
NotificationManagerCompat.from(context).notify(ID, notification)
}
}

View File

@@ -154,6 +154,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
SyncWorker.afterChange(getApplication())
}
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
app.settings.setReminderMinutes(minutes)
SyncWorker.afterChange(getApplication())

View File

@@ -57,6 +57,9 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setReminders(granted)
}
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setChangeAlerts(granted)
}
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
@@ -146,6 +149,17 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
)
}) { Text("Разрешить точное время") }
}
// On by default; Android 13+ still needs the permission, asked when switched on.
val alertsAllowed = s.changeAlerts && Reminders.canNotify(context)
SwitchRow(
"Изменения в расписании",
"Уведомление, когда университет перезалил документ и у группы что-то поменялось. Приходит при фоновом обновлении, раз в несколько часов.",
alertsAllowed,
) { on ->
if (on && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context))
changesPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
else vm.setChangeAlerts(on)
}
if (s.reminders) {
Text("За сколько минут", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(

View File

@@ -12,6 +12,7 @@ import androidx.work.PeriodicWorkRequestBuilder
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.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget
import java.util.concurrent.TimeUnit
@@ -23,7 +24,15 @@ import java.util.concurrent.TimeUnit
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val app = applicationContext.app
val before = app.repository.changes.value.map { it.at }.toSet()
app.repository.refresh()
// A change found by this run, for the open group: tell the student without waiting for
// them to open the app. The overview's card shows the same record.
val t = app.repository.timetable.value
val found = app.repository.changes.value.firstOrNull {
it.document == t?.document?.id && it.group == t.group && !it.seen && it.at !in before
}
if (found != null && app.settings.now().changeAlerts) ChangeAlerts.show(applicationContext, found)
afterChange(applicationContext)
return Result.success()
}

View File

@@ -4,6 +4,7 @@
<string name="widget_name">Сегодня</string>
<string name="widget_description">Текущая и следующая пара вашей группы</string>
<string name="channel_reminders">Перед парой</string>
<string name="channel_changes">Изменения в расписании</string>
<string name="next_widget_name">Следующая пара</string>
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
<string name="tile_name">Пара</string>

View File

@@ -192,6 +192,8 @@ final class AppModel {
afterChange()
}
func setChangeAlerts(_ on: Bool) { change { $0.changeAlerts = on } }
func setReminderMinutes(_ minutes: Int) {
change { $0.reminderMinutes = minutes }
afterChange()

View File

@@ -0,0 +1,37 @@
import Foundation
import UserNotifications
/**
* A notification when the background refresh finds that the university republished the group's
* document and something changed. Local, like the reminders: the refresh itself notices it, when
* iOS lets it run, without a push server. Opening it shows the overview with the changes card.
*/
enum ChangeAlerts {
/// "Проектирование общественных зданий: аудитория 374 → 375", up to `max` lines.
static func summary(_ record: ScheduleChanges, max: Int = 4) -> [String] {
let changed = record.changed.map { c in
let fields = c.fields.map { "\($0.label.lowercased()) \($0.from) → \($0.to)" }.joined(separator: ", ")
return c.after.lesson.subject + ": " + (fields.isEmpty ? "изменилась запись Word" : fields)
}
let added = record.added.map { row in
let when = row.date.flatMap { CivilDate($0) }?.longLabel ?? dayName(row.day).lowercased()
return "Добавлено: \(row.lesson.subject), \(when) \(row.lesson.start)"
}
let removed = record.removed.map { "Удалено: \($0.lesson.subject)" }
let lines = changed + added + removed
return lines.count > max ? Array(lines.prefix(max - 1)) + ["И ещё \(lines.count - max + 1)"] : lines
}
static func show(_ record: ScheduleChanges) async {
let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings().authorizationStatus
guard status == .authorized || status == .provisional else { return }
let content = UNMutableNotificationContent()
content.title = "Расписание \(record.group) изменилось"
let head = changesLabel(record.count) + (fileDateLabel(record.to).map { " · файл от \($0)" } ?? "")
content.body = ([head] + summary(record)).joined(separator: "\n")
content.sound = .default
content.threadIdentifier = "changes"
try? await center.add(UNNotificationRequest(identifier: "changes-\(record.document)-\(record.group)", content: content, trigger: nil))
}
}

View File

@@ -58,8 +58,16 @@ enum Background {
try? BGTaskScheduler.shared.submit(request)
}
@MainActor
static func refresh() async {
schedule()
await AppModel().refresh()
let model = AppModel()
let known = Set(model.allChanges.map(\.at))
await model.refresh()
// A change found by this run, for the open group: tell the student without waiting for
// them to open the app. The overview's card shows the same record.
if model.settings.changeAlerts, let record = model.changes, !record.seen, !known.contains(record.at) {
await ChangeAlerts.show(record)
}
}
}

View File

@@ -1,9 +1,12 @@
import SwiftUI
import UserNotifications
struct SettingsView: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
@State private var changes: ScheduleChanges?
/** Whether iOS lets the app notify; nil until asked. */
@State private var notificationsAllowed: Bool?
var body: some View {
let s = model.settings
@@ -52,6 +55,24 @@ struct SettingsView: View {
.font(.footnote).foregroundStyle(.secondary)
}
}
// On by default; the permission is asked when switched on (or for the reminders).
Toggle(isOn: Binding(get: { s.changeAlerts && notificationsAllowed != false }, set: { on in
if on {
Task {
let granted = await Reminders.authorize()
notificationsAllowed = granted
model.setChangeAlerts(granted)
}
} else { model.setChangeAlerts(false) }
})) {
VStack(alignment: .leading, spacing: 2) {
Text("Изменения в расписании")
Text(notificationsAllowed == false
? "Уведомления для приложения выключены в настройках iOS: включите их там."
: "Уведомление, когда университет перезалил документ и у группы что-то поменялось. Приходит при фоновом обновлении, когда iOS его разрешит.")
.font(.footnote).foregroundStyle(.secondary)
}
}
if s.reminders {
Picker("За сколько минут", selection: Binding(get: { s.reminderMinutes }, set: { model.setReminderMinutes($0) })) {
ForEach([5, 10, 15, 30], id: \.self) { Text("\($0)").tag($0) }
@@ -68,8 +89,8 @@ struct SettingsView: View {
if let record = model.changes {
Button { changes = record } label: {
LabeledContent("Последние изменения", value: changesLabel(record.count) + " · " + CivilDate(record.at).longLabel)
.foregroundStyle(.primary)
}
.tint(.primary)
}
LabeledContent("Сервер", value: model.api.base.absoluteString)
} header: {
@@ -82,6 +103,10 @@ struct SettingsView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
.sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) }
.task {
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
notificationsAllowed = status == .notDetermined ? nil : status == .authorized || status == .provisional
}
}
}

View File

@@ -35,6 +35,8 @@ struct UserSettings: Codable, Hashable {
var theme: ThemeMode = .system
var reminders = false
var reminderMinutes = 10
/** A notification when the background refresh finds changes in the group's document. */
var changeAlerts = true
var recent: [SavedGroup] = []
var display = Display()
/** Hidden lessons per group, as Lesson.hideKey values. */
@@ -54,6 +56,7 @@ extension UserSettings {
theme = try c.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system
reminders = try c.decodeIfPresent(Bool.self, forKey: .reminders) ?? false
reminderMinutes = try c.decodeIfPresent(Int.self, forKey: .reminderMinutes) ?? 10
changeAlerts = try c.decodeIfPresent(Bool.self, forKey: .changeAlerts) ?? true
recent = try c.decodeIfPresent([SavedGroup].self, forKey: .recent) ?? []
display = try c.decodeIfPresent(Display.self, forKey: .display) ?? Display()
hiddenByGroup = try c.decodeIfPresent([String: [String]].self, forKey: .hiddenByGroup) ?? [:]