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

@@ -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
}
}
}