feat: add change alerts for schedule updates with notifications
This commit is contained in:
@@ -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 и скрытие таких занятий.
|
||||||
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
|
- **Планы:** три макета — повестка дня с лентой недели, список и доска. Можно показывать пары дня и выполненные задания. Кнопка «+» — стеклянная.
|
||||||
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.
|
- **Календарь:** месяц с отметками пар и планов, зелёные недели подсвечены; ниже — план выбранного дня.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) ?? [:]
|
||||||
|
|||||||
Reference in New Issue
Block a user