feat: add change alerts for schedule updates with notifications
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
|
||||
|
||||
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
|
||||
app.settings.setReminderMinutes(minutes)
|
||||
SyncWorker.afterChange(getApplication())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user