diff --git a/README.md b/README.md index 74864c4..34015e3 100644 --- a/README.md +++ b/README.md @@ -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 и скрытие таких занятий. - **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная. - **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня. 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 8e39b15..b25c8ed 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 @@ -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) } } 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 5e60d72..2ca06d5 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 @@ -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 = 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>(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 -> diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/ChangeAlerts.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/ChangeAlerts.kt new file mode 100644 index 0000000..936d38a --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/ChangeAlerts.kt @@ -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 { + 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) + } +} 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 67219d4..21063d3 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 @@ -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()) 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 394b567..691a768 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 @@ -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( 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 ee4a044..eead016 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 @@ -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() } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 298770e..e5a719c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Сегодня Текущая и следующая пара вашей группы Перед парой + Изменения в расписании Следующая пара Когда следующая пара, что это и в какой аудитории Пара diff --git a/ios/PSUSchedule/AppModel.swift b/ios/PSUSchedule/AppModel.swift index fdfa45c..939bccd 100644 --- a/ios/PSUSchedule/AppModel.swift +++ b/ios/PSUSchedule/AppModel.swift @@ -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() diff --git a/ios/PSUSchedule/ChangeAlerts.swift b/ios/PSUSchedule/ChangeAlerts.swift new file mode 100644 index 0000000..ce43bc6 --- /dev/null +++ b/ios/PSUSchedule/ChangeAlerts.swift @@ -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)) + } +} diff --git a/ios/PSUSchedule/PSUScheduleApp.swift b/ios/PSUSchedule/PSUScheduleApp.swift index 03dfd24..5520177 100644 --- a/ios/PSUSchedule/PSUScheduleApp.swift +++ b/ios/PSUSchedule/PSUScheduleApp.swift @@ -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) + } } } diff --git a/ios/PSUSchedule/Views/SettingsView.swift b/ios/PSUSchedule/Views/SettingsView.swift index c423f33..9c1d288 100644 --- a/ios/PSUSchedule/Views/SettingsView.swift +++ b/ios/PSUSchedule/Views/SettingsView.swift @@ -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 + } } } diff --git a/ios/Shared/Storage.swift b/ios/Shared/Storage.swift index 6a8c83f..500967f 100644 --- a/ios/Shared/Storage.swift +++ b/ios/Shared/Storage.swift @@ -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) ?? [:]