feat: add travel and room alerts, live lesson notifications, and launcher shortcuts

- Implemented travel alerts for notifying about classes in another town the evening before.
- Added room alerts for notifying about room changes on the morning of the class.
- Introduced live lesson notifications to provide ongoing updates during classes.
- Created launcher shortcuts for quick access to today's schedule, weekly overview, search functionality, and adding new tasks.
- Updated settings to manage new notification preferences and integrated them into the user interface.
- Enhanced notification channels for better organization and user experience.
This commit is contained in:
2026-10-23 01:38:06 +03:00
parent 02e6404731
commit a3df53713f
20 changed files with 669 additions and 17 deletions

View File

@@ -263,6 +263,12 @@ DOMAIN=schedule.example.by docker compose --profile https up -d --build
- Поиск по университету: занятия преподавателя или аудитории на выбранный день.
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
- **Уведомления о дне** (настройки → «Уведомления о дне», тоже локальные, из сохранённого расписания):
- «Занятия в другом городе» — вечером накануне такого дня (18:00–21:00, по умолчанию 20:00) приходит «Завтра занятия в Полоцке» с первой парой, аудиторией и числом пар. Напоминание о первой паре этого дня, если напоминания включены, приходит раньше обычного на 0–60 минут (по умолчанию 30).
- «Переносы аудиторий» — утром в день переноса (6:30–8:00, по умолчанию 7:00, но не позже чем за час до пары) приходит «Сегодня перенос аудитории: 15:20 Возрастная физиология… — ауд. 100, обычно 402Н».
- Оба включены по умолчанию. Нажатие открывает этот день в «Расписании».
- **Текущая пара в шторке** (выключена по умолчанию): пока идёт пара, в шторке висит уведомление — предмет, «до 11:30 · ауд. 205Г · Полоцк», следующая пара и прогресс по половинам пары из расписания звонков. На Android 16 это Live Update: в строке состояния плашка с обратным отсчётом до конца пары, на экране блокировки — само уведомление. Оно появляется по будильнику в начале пары, обновляется раз в несколько минут и само исчезает в конце.
- **Ярлыки на иконке** (долгое нажатие): «Пары на сегодня», «Неделя», «Найти аудиторию», «Новое задание» (открывает «Планы» с выбором пары).
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).

View File

@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Optional: the student can allow exact reminder times in system settings. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- The lesson under way as a Live Update on Android 16 (a promoted ongoing notification). -->
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<application
android:name=".ScheduleApp"
@@ -20,11 +22,16 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Long press on the icon: today, the week, room search, a new task. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<receiver
@@ -66,6 +73,14 @@
android:name=".notify.ReminderReceiver"
android:exported="false" />
<receiver
android:name=".notify.DayAlertReceiver"
android:exported="false" />
<receiver
android:name=".notify.LiveLessonReceiver"
android:exported="false" />
<receiver
android:name=".notify.BootReceiver"
android:exported="true">

View File

@@ -0,0 +1,49 @@
package org.xtimms.psu.schedule
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import java.time.LocalDate
/**
* Where the app opens from outside: a launcher shortcut (res/xml/shortcuts.xml) or a
* notification. The intent carries a target and, for a day, its date.
*/
sealed interface Launch {
data object Today : Launch
data object Week : Launch
data object Search : Launch
data object NewTask : Launch
data class Day(val date: LocalDate) : Launch
companion object {
const val ACTION = "org.xtimms.psu.schedule.OPEN"
private const val TARGET = "target"
private const val DATE = "date"
fun of(intent: Intent?): Launch? {
if (intent?.action != ACTION) return null
return when (intent.getStringExtra(TARGET)) {
"today" -> Today
"week" -> Week
"search" -> Search
"task" -> NewTask
"day" -> intent.getStringExtra(DATE)?.let { runCatching { Day(LocalDate.parse(it)) }.getOrNull() }
else -> null
}
}
/** Opens a day of the timetable, for a notification about it. */
fun day(context: Context, date: LocalDate, request: Int): PendingIntent =
PendingIntent.getActivity(
context,
request,
Intent(context, MainActivity::class.java)
.setAction(ACTION)
.putExtra(TARGET, "day")
.putExtra(DATE, date.toString())
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}

View File

@@ -150,9 +150,19 @@ private val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels()
/** A launcher shortcut or a notification asked for a screen (see Launch.kt). */
private val launch = androidx.compose.runtime.mutableStateOf<Launch?>(null)
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
Launch.of(intent)?.let { launch.value = it }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// A recreated activity (rotation) must not open the same screen again.
if (savedInstanceState == null) launch.value = Launch.of(intent)
enableEdgeToEdge()
setContent {
val settings by vm.settings.collectAsStateWithLifecycle()
@@ -181,7 +191,7 @@ class MainActivity : ComponentActivity() {
label = "onboarding",
) { first ->
if (first) Onboarding(vm, onDone = { onboarding = false })
else App(vm, firstRun = s.current == null)
else App(vm, firstRun = s.current == null, launch = launch.value, onLaunched = { launch.value = null })
}
}
}
@@ -199,7 +209,7 @@ private val EXPANDED_WIDTH = 840.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun App(vm: MainViewModel, firstRun: Boolean) {
private fun App(vm: MainViewModel, firstRun: Boolean, launch: Launch?, onLaunched: () -> Unit) {
var tab by rememberSaveable { mutableStateOf(Tab.Home) }
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
// Unset until the student picks: wide windows open the schedule as a week grid.
@@ -273,6 +283,23 @@ private fun App(vm: MainViewModel, firstRun: Boolean) {
openMenu = if (wide) null else ({ menuOpen = true }),
)
}
// A shortcut or a notification: its screen, once the student has a group.
LaunchedEffect(launch, hasGroup) {
if (launch == null || !hasGroup) return@LaunchedEffect
page = null
menuOpen = false
when (launch) {
Launch.Today -> nav.openDay(today())
Launch.Week -> nav.openWeek()
Launch.Search -> nav.openSearch()
Launch.NewTask -> {
nav.openPlans()
vm.newTask.value = true
}
is Launch.Day -> nav.openDay(launch.date)
}
onLaunched()
}
val menuItems = MenuItems(
tab = tab,
page = page,

View File

@@ -27,6 +27,8 @@ class ScheduleApp : Application() {
attendance = AttendanceStore(this)
Reminders.createChannel(this)
org.xtimms.psu.schedule.notify.ChangeAlerts.createChannel(this)
org.xtimms.psu.schedule.notify.DayAlerts.createChannel(this)
org.xtimms.psu.schedule.notify.LiveLesson.createChannel(this)
SyncWorker.schedule(this)
}
}

View File

@@ -43,6 +43,19 @@ data class UserSettings(
val reminderMinutes: Int = 10,
/** A notification when the background sync finds changes in the group's document. */
val changeAlerts: Boolean = true,
/**
* "Завтра занятия в Полоцке": a notification the evening before a day in another town, at
* [travelTime] (minutes after midnight), and the reminder of that day's first lesson
* [travelLead] minutes earlier than usual.
*/
val travelAlerts: Boolean = true,
val travelTime: Int = 20 * 60,
val travelLead: Int = 30,
/** A notification on the morning of a day with a room move, at [roomTime]. */
val roomAlerts: Boolean = true,
val roomTime: Int = 7 * 60,
/** The lesson under way as an ongoing notification (a Live Update on Android 16). */
val liveLesson: Boolean = false,
val recent: List<SavedGroup> = emptyList(),
val display: Display = Display(),
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
@@ -62,6 +75,12 @@ class Settings(private val context: Context) {
val reminders = booleanPreferencesKey("reminders")
val minutes = intPreferencesKey("reminder_minutes")
val changeAlerts = booleanPreferencesKey("change_alerts")
val travelAlerts = booleanPreferencesKey("travel_alerts")
val travelTime = intPreferencesKey("travel_time")
val travelLead = intPreferencesKey("travel_lead")
val roomAlerts = booleanPreferencesKey("room_alerts")
val roomTime = intPreferencesKey("room_time")
val liveLesson = booleanPreferencesKey("live_lesson")
val recent = stringPreferencesKey("recent")
val colors = booleanPreferencesKey("show_colors")
val teachers = booleanPreferencesKey("show_teachers")
@@ -95,6 +114,12 @@ class Settings(private val context: Context) {
reminders = this[Keys.reminders] ?: false,
reminderMinutes = this[Keys.minutes] ?: 10,
changeAlerts = this[Keys.changeAlerts] ?: true,
travelAlerts = this[Keys.travelAlerts] ?: true,
travelTime = this[Keys.travelTime] ?: 20 * 60,
travelLead = this[Keys.travelLead] ?: 30,
roomAlerts = this[Keys.roomAlerts] ?: true,
roomTime = this[Keys.roomTime] ?: 7 * 60,
liveLesson = this[Keys.liveLesson] ?: false,
recent = this[Keys.recent]?.let {
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
}.orEmpty(),
@@ -149,6 +174,12 @@ class Settings(private val context: Context) {
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 }
suspend fun setTravelAlerts(on: Boolean) = context.store.edit { it[Keys.travelAlerts] = on }
suspend fun setTravelTime(minutes: Int) = context.store.edit { it[Keys.travelTime] = minutes }
suspend fun setTravelLead(minutes: Int) = context.store.edit { it[Keys.travelLead] = minutes }
suspend fun setRoomAlerts(on: Boolean) = context.store.edit { it[Keys.roomAlerts] = on }
suspend fun setRoomTime(minutes: Int) = context.store.edit { it[Keys.roomTime] = minutes }
suspend fun setLiveLesson(on: Boolean) = context.store.edit { it[Keys.liveLesson] = 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,143 @@
package org.xtimms.psu.schedule.notify
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.xtimms.psu.schedule.Launch
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.MINSK
import org.xtimms.psu.schedule.data.Repository
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.data.parseDate
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.plural
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
/**
* What the day has in store, told in advance, from the cached timetable like the reminders:
* "Завтра занятия в Полоцке" the evening before a day in another town, and the morning's room
* moves ("10:05 Типология — ауд. 333, обычно 412Н"). Each has its switch and time in settings.
*/
object DayAlerts {
private const val CHANNEL = "day_alerts"
private const val ACTION = "org.xtimms.psu.schedule.DAY_ALERT"
// Request codes: a week of travel evenings, then a week of room-move mornings.
private const val TRAVEL = 0
private const val ROOMS = 16
private const val MAX = 16
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL,
context.getString(R.string.channel_day),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = "Накануне дня в другом городе и утром в день переноса аудитории" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun intent(context: Context, code: Int, extras: Intent.() -> Unit = {}): PendingIntent =
PendingIntent.getBroadcast(
context,
code,
Intent(context, DayAlertReceiver::class.java).setAction(ACTION).apply(extras),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private fun rooms(l: Lesson) = l.rooms.joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" }
/** Replaces the scheduled alerts with the ones of the coming week. */
fun reschedule(context: Context, timetable: Timetable?, settings: UserSettings, repository: Repository) {
val alarms = context.getSystemService(AlarmManager::class.java)
repeat(ROOMS + MAX) { alarms.cancel(intent(context, it)) }
if (timetable == null) return
val now = Instant.now()
val days = (0..7L).map { today().plusDays(it) }
var travel = 0
var moves = 0
for (date in days) {
val lessons = repository.lessonsOn(timetable, date, settings.subgroup, settings.hidden).sortedBy { it.start }
val first = lessons.firstOrNull() ?: continue
val places = repository.placesOn(timetable, date, settings.subgroup, settings.hidden)
if (settings.travelAlerts && places.isNotEmpty() && travel < MAX) {
val at = date.minusDays(1).atTime(LocalTime.ofSecondOfDay(settings.travelTime * 60L)).atZone(MINSK).toInstant()
if (at.isAfter(now)) {
val note = places.first()
val title = "Завтра " + note.text.replaceFirstChar { it.lowercase() } + (note.subgroup?.let { " · ${it.lowercase()}" } ?: "")
val text = "Первая пара в ${first.start} — ${first.subject}" +
(withPlace(rooms(first), first.place)?.let { ", $it" } ?: "") +
". Всего ${lessons.distinctBy { it.start }.size} ${plural(lessons.distinctBy { it.start }.size, "пара", "пары", "пар")}."
Reminders.alarm(context, at.toEpochMilli(), intent(context, TRAVEL + travel) {
putExtra("id", TRAVEL + travel)
putExtra("title", title)
putExtra("text", text)
putExtra("date", date.toString())
})
travel++
}
}
val moved = lessons.filter { it.roomMoved }
if (settings.roomAlerts && moved.isNotEmpty() && moves < MAX) {
// The chosen morning time, or an hour before the first moved lesson if that is earlier.
val firstMoved = date.atTime(parseTime(moved.first().start))
val morning = date.atTime(LocalTime.ofSecondOfDay(settings.roomTime * 60L))
val at = minOf(morning, firstMoved.minusHours(1)).atZone(MINSK).toInstant()
if (at.isAfter(now)) {
// The usual room comes from the Word row of the weekday: same lesson key.
val usual = timetable.weekly.flatMap { it.lessons }.associateBy { it.key }
val lines = moved.map { l ->
val before = usual[l.key]?.rooms?.joinToString(", ")?.takeIf { it.isNotEmpty() && it != l.rooms.joinToString(", ") }
"${l.start} ${l.subject} — ${rooms(l) ?: "аудитория не указана"}" + (before?.let { ", обычно $it" } ?: "")
}
// It comes on the day itself.
val title = if (moved.size == 1) "Сегодня перенос аудитории" else "Сегодня переносов аудиторий: ${moved.size}"
Reminders.alarm(context, at.toEpochMilli(), intent(context, ROOMS + moves) {
putExtra("id", ROOMS + moves)
putExtra("title", title)
putExtra("text", lines.joinToString("\n"))
putExtra("date", date.toString())
})
moves++
}
}
}
}
fun show(context: Context, id: Int, title: String, text: String, date: LocalDate) {
if (!Reminders.canNotify(context)) return
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text.lineSequence().first())
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(Launch.day(context, date, 2000 + id))
.setAutoCancel(true)
.build()
@Suppress("MissingPermission")
NotificationManagerCompat.from(context).notify(2000 + id, notification)
}
}
class DayAlertReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
DayAlerts.show(
context,
intent.getIntExtra("id", 0),
intent.getStringExtra("title") ?: return,
intent.getStringExtra("text").orEmpty(),
intent.getStringExtra("date")?.let(::parseDate) ?: today(),
)
}
}

View File

@@ -0,0 +1,164 @@
package org.xtimms.psu.schedule.notify
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.xtimms.psu.schedule.Launch
import org.xtimms.psu.schedule.R
import org.xtimms.psu.schedule.app
import org.xtimms.psu.schedule.data.Lesson
import org.xtimms.psu.schedule.data.MINSK
import org.xtimms.psu.schedule.data.Repository
import org.xtimms.psu.schedule.data.Timetable
import org.xtimms.psu.schedule.data.UserSettings
import org.xtimms.psu.schedule.data.nowDateTime
import org.xtimms.psu.schedule.data.parseTime
import org.xtimms.psu.schedule.data.today
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.ui.lessonEnd
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
/**
* The lesson under way as an ongoing notification: the subject, the room, how long is left
* and the next lesson. On Android 16 it is a Live Update (a promoted ongoing notification with
* a progress bar in the pair's two halves) in the status bar and on the lock screen. An alarm
* at each lesson's start shows it; while the lesson runs it refreshes every few minutes and it
* leaves by itself at the end. Off by default ("Текущая пара в шторке").
*/
object LiveLesson {
private const val CHANNEL = "lesson_now"
private const val ID = 3000
private const val ACTION = "org.xtimms.psu.schedule.LIVE_LESSON"
private const val MAX = 48
// The refresh while a lesson runs, after the start alarms' request codes.
private const val TICK = MAX
private const val EVERY = 5 * 60 * 1000L
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL,
context.getString(R.string.channel_now),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Идущая пара: сколько осталось и что дальше"
setSound(null, null)
enableVibration(false)
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun intent(context: Context, code: Int): PendingIntent =
PendingIntent.getBroadcast(
context,
code,
Intent(context, LiveLessonReceiver::class.java).setAction(ACTION),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private class Slot(val start: LocalDateTime, val end: LocalDateTime, val lessons: List<Lesson>)
private fun slots(t: Timetable, date: LocalDate, s: UserSettings, repository: Repository) =
repository.lessonsOn(t, date, s.subgroup, s.hidden)
.groupBy { it.start }
.map { (start, lessons) -> Slot(date.atTime(parseTime(start)), date.atTime(lessonEnd(lessons.first(), t)), lessons) }
.sortedBy { it.start }
/** Alarms at the starts of the coming lessons, and the lesson under way shown now. */
fun reschedule(context: Context, t: Timetable?, s: UserSettings, repository: Repository) {
val alarms = context.getSystemService(AlarmManager::class.java)
repeat(MAX + 1) { alarms.cancel(intent(context, it)) }
if (!s.liveLesson || t == null) {
NotificationManagerCompat.from(context).cancel(ID)
return
}
val now = nowDateTime()
var code = 0
for (offset in 0..2L) for (slot in slots(t, today().plusDays(offset), s, repository)) {
if (code >= MAX) break
if (slot.start.isAfter(now)) Reminders.alarm(context, slot.start.atZone(MINSK).toInstant().toEpochMilli(), intent(context, code++))
}
update(context, t, s, repository)
}
/** Shows, refreshes or removes the notification for this moment. */
fun update(context: Context, t: Timetable?, s: UserSettings, repository: Repository) {
val manager = NotificationManagerCompat.from(context)
val now = nowDateTime()
val day = if (t != null && s.liveLesson) slots(t, today(), s, repository) else emptyList()
val slot = day.firstOrNull { !now.isBefore(it.start) && now.isBefore(it.end) }
if (slot == null || !Reminders.canNotify(context) || t == null) {
manager.cancel(ID)
return
}
val next = day.firstOrNull { it.start >= slot.end }
val first = slot.lessons.first()
val total = Duration.between(slot.start, slot.end).toMinutes().toInt().coerceAtLeast(1)
val passed = Duration.between(slot.start, now).toMinutes().toInt().coerceIn(0, total)
val room = withPlace(
slot.lessons.flatMap { it.rooms }.distinct().joinToString(", ").takeIf { it.isNotEmpty() }?.let { "ауд. $it" },
slot.lessons.firstNotNullOfOrNull { it.place },
)
// The pair's halves from the bell table ("08:30–09:10, 09:15–09:55"): one segment each.
val halves = t.bells.firstOrNull { it.start == first.start }?.halves.orEmpty()
.mapNotNull { h -> runCatching { Duration.between(slot.start.toLocalTime(), parseTime(h[1])).toMinutes().toInt() }.getOrNull() }
.filter { it in 1 until total }
val progress = NotificationCompat.ProgressStyle()
.setStyledByProgress(true)
.setProgressSegments(
(listOf(0) + halves + total).zipWithNext { a, b -> b - a }
.filter { it > 0 }
.map { NotificationCompat.ProgressStyle.Segment(it) },
)
.setProgress(passed)
val text = listOfNotNull("до ${slot.end.toLocalTime()}", room).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(slot.lessons.joinToString(" / ") { it.subject })
.setContentText(text)
.setSubText(next?.let { "Дальше в ${it.start.toLocalTime()} — ${it.lessons.first().subject}" })
.setStyle(progress)
// The status bar chip counts down to the end by itself (a short text would replace it).
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setWhen(slot.end.atZone(MINSK).toInstant().toEpochMilli())
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSilent(true)
.setRequestPromotedOngoing(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setTimeoutAfter(Duration.between(now, slot.end).toMillis())
.setContentIntent(Launch.day(context, today(), ID))
.build()
@Suppress("MissingPermission")
manager.notify(ID, notification)
// The next refresh: in a few minutes, or at the end to take the notification away.
val nextTick = minOf(now.plusNanos(EVERY * 1_000_000), slot.end).atZone(MINSK).toInstant().toEpochMilli()
context.getSystemService(AlarmManager::class.java)
.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextTick, intent(context, TICK))
}
}
class LiveLessonReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
val app = context.app
LiveLesson.update(context, app.repository.timetable.value, app.settings.now(), app.repository)
} finally {
pending.finish()
}
}
}
}

View File

@@ -2,6 +2,7 @@ package org.xtimms.psu.schedule.notify
import org.xtimms.psu.schedule.data.hideKey
import org.xtimms.psu.schedule.data.withPlace
import org.xtimms.psu.schedule.data.forSubgroup
import android.Manifest
import android.app.AlarmManager
import android.app.NotificationChannel
@@ -76,13 +77,17 @@ object Reminders {
var slot = 0
for (day in timetable.days) {
val date = parseDate(day.date)
// One reminder per start time: parallel subgroups share the notification.
for ((start, lessons) in day.lessons
val shown = day.lessons
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
.filter { it.hideKey !in settings.hidden }
.groupBy { it.start }) {
// A day in another town: the first lesson is announced earlier, for the road.
val away = settings.travelAlerts && day.places.forSubgroup(settings.subgroup).isNotEmpty()
val first = shown.minOfOrNull { it.start }
// One reminder per start time: parallel subgroups share the notification.
for ((start, lessons) in shown.groupBy { it.start }) {
val begins = date.atTime(parseTime(start)).atZone(MINSK).toInstant()
val at = begins.minusSeconds(settings.reminderMinutes * 60L)
val lead = settings.reminderMinutes + if (away && start == first) settings.travelLead else 0
val at = begins.minusSeconds(lead * 60L)
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
val first = lessons.first()
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
@@ -101,18 +106,24 @@ object Reminders {
putExtra("text", text)
putExtra("id", slot)
}
if (canBeExact(context)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at.toEpochMilli(), pending)
} else {
// Without exact alarms Android may deliver up to an hour late; a
// 10-minute window (the shortest allowed) centred on the time keeps it useful.
alarms.setWindow(AlarmManager.RTC_WAKEUP, at.toEpochMilli() - WINDOW / 2, WINDOW, pending)
}
alarm(context, at.toEpochMilli(), pending)
slot++
}
}
}
/** An alarm at [at], exact when the student allowed it. */
fun alarm(context: Context, at: Long, pending: PendingIntent) {
val alarms = context.getSystemService(AlarmManager::class.java)
if (canBeExact(context)) {
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pending)
} else {
// Without exact alarms Android may deliver up to an hour late; a
// 10-minute window (the shortest allowed) centred on the time keeps it useful.
alarms.setWindow(AlarmManager.RTC_WAKEUP, at - WINDOW / 2, WINDOW, pending)
}
}
fun show(context: Context, id: Int, title: String, text: String) {
if (!canNotify(context)) return
val open = PendingIntent.getActivity(
@@ -151,8 +162,8 @@ class BootReceiver : BroadcastReceiver() {
val pending = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
// Reminders, day alerts and the live lesson, as after any change.
org.xtimms.psu.schedule.work.SyncWorker.afterChange(context)
} finally {
pending.finish()
}

View File

@@ -161,6 +161,8 @@ fun <T> ConnectedChoice(
selected: T,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
mark: Boolean = true,
) {
Row(
modifier.selectableGroup(),
@@ -185,7 +187,7 @@ fun <T> ConnectedChoice(
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
) {
if (checked) {
if (checked && mark) {
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
}

View File

@@ -166,6 +166,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun setChangeAlerts(on: Boolean) = viewModelScope.launch { app.settings.setChangeAlerts(on) }
/** "Новое задание" from the launcher: the plans open with the add sheet. */
val newTask = kotlinx.coroutines.flow.MutableStateFlow(false)
/** Day alerts and the live lesson are alarms too: set them again after every change. */
private fun alerts(change: suspend () -> Unit) = viewModelScope.launch {
change()
SyncWorker.afterChange(getApplication())
}
fun setTravelAlerts(on: Boolean) = alerts { app.settings.setTravelAlerts(on) }
fun setTravelTime(minutes: Int) = alerts { app.settings.setTravelTime(minutes) }
fun setTravelLead(minutes: Int) = alerts { app.settings.setTravelLead(minutes) }
fun setRoomAlerts(on: Boolean) = alerts { app.settings.setRoomAlerts(on) }
fun setRoomTime(minutes: Int) = alerts { app.settings.setRoomTime(minutes) }
fun setLiveLesson(on: Boolean) = alerts { app.settings.setLiveLesson(on) }
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
app.settings.setReminderMinutes(minutes)
SyncWorker.afterChange(getApplication())

View File

@@ -125,6 +125,13 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
val prefs = settings?.plans ?: PlansPrefs()
var editing by remember { mutableStateOf<Note?>(null) }
var adding by remember { mutableStateOf(false) }
val wantsTask by vm.newTask.collectAsStateWithLifecycle()
LaunchedEffect(wantsTask) {
if (wantsTask) {
adding = true
vm.newTask.value = false
}
}
var tuning by remember { mutableStateOf(false) }
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
var target by remember { mutableStateOf<NoteTarget?>(null) }

View File

@@ -60,6 +60,20 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
vm.setChangeAlerts(granted)
}
// The day's alerts ask for the same permission; the switch that asked gets the answer.
var afterGrant by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<((Boolean) -> Unit)?>(null) }
val dayPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
afterGrant?.invoke(granted)
afterGrant = null
}
/** A switch that needs notifications: asks first on Android 13+, then sets the setting. */
fun notifying(set: (Boolean) -> Unit): (Boolean) -> Unit = { on ->
if (on && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !Reminders.canNotify(context)) {
afterGrant = set
dayPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
} else set(on)
}
val allowed = Reminders.canNotify(context)
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
@@ -170,6 +184,56 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
)
}
}
Section("Уведомления о дне") {
SwitchRow(
"Занятия в другом городе",
"Вечером накануне — «Завтра занятия в Полоцке» с первой парой и аудиторией. Если включены напоминания, о первой паре такого дня напомнит раньше: нужно время на дорогу.",
s.travelAlerts && allowed,
notifying(vm::setTravelAlerts),
)
if (s.travelAlerts && allowed) {
Text("Вечером в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(18 * 60 to "18:00", 19 * 60 to "19:00", 20 * 60 to "20:00", 21 * 60 to "21:00"),
selected = s.travelTime,
onSelect = vm::setTravelTime,
modifier = Modifier.fillMaxWidth(),
mark = false,
)
if (s.reminders) {
Text("О первой паре — раньше на, мин", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(0 to "0", 15 to "15", 30 to "30", 45 to "45", 60 to "60"),
selected = s.travelLead,
onSelect = vm::setTravelLead,
modifier = Modifier.fillMaxWidth(),
mark = false,
)
}
}
SwitchRow(
"Переносы аудиторий",
"Утром в день переноса — какая пара и в какой аудитории, вместо обычной.",
s.roomAlerts && allowed,
notifying(vm::setRoomAlerts),
)
if (s.roomAlerts && allowed) {
Text("Утром в", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ConnectedChoice(
options = listOf(6 * 60 + 30 to "6:30", 7 * 60 to "7:00", 7 * 60 + 30 to "7:30", 8 * 60 to "8:00"),
selected = s.roomTime,
onSelect = vm::setRoomTime,
modifier = Modifier.fillMaxWidth(),
mark = false,
)
}
SwitchRow(
"Текущая пара в шторке",
"Пока идёт пара — уведомление с оставшимся временем и следующей парой. На Android 16 оно видно в строке состояния и на экране блокировки.",
s.liveLesson && allowed,
notifying(vm::setLiveLesson),
)
}
Section("О расписании") {
val doc = timetable?.document
Info("Документ", doc?.name ?: "Группа не выбрана")

View File

@@ -13,6 +13,8 @@ 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.DayAlerts
import org.xtimms.psu.schedule.notify.LiveLesson
import org.xtimms.psu.schedule.notify.Reminders
import org.xtimms.psu.schedule.widget.TodayWidget
import java.util.concurrent.TimeUnit
@@ -52,7 +54,11 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c
/** Widget and reminders follow the cached timetable and settings. */
suspend fun afterChange(context: Context) {
val app = context.app
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
val t = app.repository.timetable.value
val s = app.settings.now()
Reminders.reschedule(context, t, s)
DayAlerts.reschedule(context, t, s, app.repository)
LiveLesson.reschedule(context, t, s, app.repository)
TodayWidget().updateAll(context)
NextLessonWidget().updateAll(context)
LessonTileService.refresh(context)

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: найти аудиторию. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF0061A4"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: новое задание. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF0061A4"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: сегодня. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF0061A4"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher shortcut: неделя. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF0061A4"
android:pathData="M13,5h-2c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1V6c0,-0.55 -0.45,-1 -1,-1zM20,5h-2c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1V6c0,-0.55 -0.45,-1 -1,-1zM6,5H4c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1V6c0,-0.55 -0.45,-1 -1,-1z" />
</vector>

View File

@@ -5,6 +5,16 @@
<string name="widget_description">Текущая и следующая пара вашей группы</string>
<string name="channel_reminders">Перед парой</string>
<string name="channel_changes">Изменения в расписании</string>
<string name="channel_day">Поездки и переносы</string>
<string name="channel_now">Текущая пара</string>
<string name="shortcut_today">Сегодня</string>
<string name="shortcut_today_long">Пары на сегодня</string>
<string name="shortcut_week">Неделя</string>
<string name="shortcut_week_long">Вся неделя</string>
<string name="shortcut_search">Найти аудиторию</string>
<string name="shortcut_search_long">Найти аудиторию</string>
<string name="shortcut_task">Новое задание</string>
<string name="shortcut_task_long">Новое задание</string>
<string name="next_widget_name">Следующая пара</string>
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
<string name="tile_name">Пара</string>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Long press on the app icon (Launch.kt reads the target). -->
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="today"
android:enabled="true"
android:icon="@drawable/ic_shortcut_today"
android:shortcutShortLabel="@string/shortcut_today"
android:shortcutLongLabel="@string/shortcut_today_long">
<intent
android:action="org.xtimms.psu.schedule.OPEN"
android:targetPackage="org.xtimms.psu.schedule"
android:targetClass="org.xtimms.psu.schedule.MainActivity">
<extra android:name="target" android:value="today" />
</intent>
</shortcut>
<shortcut
android:shortcutId="week"
android:enabled="true"
android:icon="@drawable/ic_shortcut_week"
android:shortcutShortLabel="@string/shortcut_week"
android:shortcutLongLabel="@string/shortcut_week_long">
<intent
android:action="org.xtimms.psu.schedule.OPEN"
android:targetPackage="org.xtimms.psu.schedule"
android:targetClass="org.xtimms.psu.schedule.MainActivity">
<extra android:name="target" android:value="week" />
</intent>
</shortcut>
<shortcut
android:shortcutId="search"
android:enabled="true"
android:icon="@drawable/ic_shortcut_search"
android:shortcutShortLabel="@string/shortcut_search"
android:shortcutLongLabel="@string/shortcut_search_long">
<intent
android:action="org.xtimms.psu.schedule.OPEN"
android:targetPackage="org.xtimms.psu.schedule"
android:targetClass="org.xtimms.psu.schedule.MainActivity">
<extra android:name="target" android:value="search" />
</intent>
</shortcut>
<shortcut
android:shortcutId="task"
android:enabled="true"
android:icon="@drawable/ic_shortcut_task"
android:shortcutShortLabel="@string/shortcut_task"
android:shortcutLongLabel="@string/shortcut_task_long">
<intent
android:action="org.xtimms.psu.schedule.OPEN"
android:targetPackage="org.xtimms.psu.schedule"
android:targetClass="org.xtimms.psu.schedule.MainActivity">
<extra android:name="target" android:value="task" />
</intent>
</shortcut>
</shortcuts>