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

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

View File

@@ -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<SavedGroup> = 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<List<SavedGroup>>(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 ->

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())
}
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
app.settings.setReminderMinutes(minutes)
SyncWorker.afterChange(getApplication())

View File

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

View File

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

View File

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