feat: add change alerts for schedule updates with notifications
This commit is contained in:
@@ -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()
|
||||
|
||||
37
ios/PSUSchedule/ChangeAlerts.swift
Normal file
37
ios/PSUSchedule/ChangeAlerts.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user