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

View File

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

View File

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

View File

@@ -57,6 +57,9 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setReminders(granted) vm.setReminders(granted)
} }
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setChangeAlerts(granted)
}
val scroll = TopAppBarDefaults.pinnedScrollBehavior() val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) { Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
@@ -146,6 +149,17 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
) )
}) { Text("Разрешить точное время") } }) { 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) { if (s.reminders) {
Text("За сколько минут", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("За сколько минут", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice( ConnectedChoice(

View File

@@ -12,6 +12,7 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import org.xtimms.psu.schedule.app 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.notify.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget import org.xtimms.psu.schedule.widget.TodayWidget
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -23,7 +24,15 @@ import java.util.concurrent.TimeUnit
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val app = applicationContext.app val app = applicationContext.app
val before = app.repository.changes.value.map { it.at }.toSet()
app.repository.refresh() 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) afterChange(applicationContext)
return Result.success() return Result.success()
} }

View File

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

View File

@@ -192,6 +192,8 @@ final class AppModel {
afterChange() afterChange()
} }
func setChangeAlerts(_ on: Bool) { change { $0.changeAlerts = on } }
func setReminderMinutes(_ minutes: Int) { func setReminderMinutes(_ minutes: Int) {
change { $0.reminderMinutes = minutes } change { $0.reminderMinutes = minutes }
afterChange() 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) try? BGTaskScheduler.shared.submit(request)
} }
@MainActor
static func refresh() async { static func refresh() async {
schedule() 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 SwiftUI
import UserNotifications
struct SettingsView: View { struct SettingsView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var changes: ScheduleChanges? @State private var changes: ScheduleChanges?
/** Whether iOS lets the app notify; nil until asked. */
@State private var notificationsAllowed: Bool?
var body: some View { var body: some View {
let s = model.settings let s = model.settings
@@ -52,6 +55,24 @@ struct SettingsView: View {
.font(.footnote).foregroundStyle(.secondary) .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 { if s.reminders {
Picker("За сколько минут", selection: Binding(get: { s.reminderMinutes }, set: { model.setReminderMinutes($0) })) { Picker("За сколько минут", selection: Binding(get: { s.reminderMinutes }, set: { model.setReminderMinutes($0) })) {
ForEach([5, 10, 15, 30], id: \.self) { Text("\($0)").tag($0) } ForEach([5, 10, 15, 30], id: \.self) { Text("\($0)").tag($0) }
@@ -68,8 +89,8 @@ struct SettingsView: View {
if let record = model.changes { if let record = model.changes {
Button { changes = record } label: { Button { changes = record } label: {
LabeledContent("Последние изменения", value: changesLabel(record.count) + " · " + CivilDate(record.at).longLabel) LabeledContent("Последние изменения", value: changesLabel(record.count) + " · " + CivilDate(record.at).longLabel)
.foregroundStyle(.primary)
} }
.tint(.primary)
} }
LabeledContent("Сервер", value: model.api.base.absoluteString) LabeledContent("Сервер", value: model.api.base.absoluteString)
} header: { } header: {
@@ -82,6 +103,10 @@ struct SettingsView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
.sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) } .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 theme: ThemeMode = .system
var reminders = false var reminders = false
var reminderMinutes = 10 var reminderMinutes = 10
/** A notification when the background refresh finds changes in the group's document. */
var changeAlerts = true
var recent: [SavedGroup] = [] var recent: [SavedGroup] = []
var display = Display() var display = Display()
/** Hidden lessons per group, as Lesson.hideKey values. */ /** Hidden lessons per group, as Lesson.hideKey values. */
@@ -54,6 +56,7 @@ extension UserSettings {
theme = try c.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system theme = try c.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system
reminders = try c.decodeIfPresent(Bool.self, forKey: .reminders) ?? false reminders = try c.decodeIfPresent(Bool.self, forKey: .reminders) ?? false
reminderMinutes = try c.decodeIfPresent(Int.self, forKey: .reminderMinutes) ?? 10 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) ?? [] recent = try c.decodeIfPresent([SavedGroup].self, forKey: .recent) ?? []
display = try c.decodeIfPresent(Display.self, forKey: .display) ?? Display() display = try c.decodeIfPresent(Display.self, forKey: .display) ?? Display()
hiddenByGroup = try c.decodeIfPresent([String: [String]].self, forKey: .hiddenByGroup) ?? [:] hiddenByGroup = try c.decodeIfPresent([String: [String]].self, forKey: .hiddenByGroup) ?? [:]