From a3df53713fc7390f68b3688e28cd8b885df6cd20 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Fri, 23 Oct 2026 01:38:06 +0300 Subject: [PATCH] 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. --- README.md | 6 + android/app/src/main/AndroidManifest.xml | 15 ++ .../kotlin/org/xtimms/psu/schedule/Launch.kt | 49 ++++++ .../org/xtimms/psu/schedule/MainActivity.kt | 31 +++- .../org/xtimms/psu/schedule/ScheduleApp.kt | 2 + .../org/xtimms/psu/schedule/data/Settings.kt | 31 ++++ .../xtimms/psu/schedule/notify/DayAlerts.kt | 143 +++++++++++++++ .../xtimms/psu/schedule/notify/LiveLesson.kt | 164 ++++++++++++++++++ .../xtimms/psu/schedule/notify/Reminders.kt | 37 ++-- .../org/xtimms/psu/schedule/ui/Common.kt | 4 +- .../xtimms/psu/schedule/ui/MainViewModel.kt | 15 ++ .../org/xtimms/psu/schedule/ui/PlansScreen.kt | 7 + .../xtimms/psu/schedule/ui/SettingsScreen.kt | 64 +++++++ .../xtimms/psu/schedule/work/SyncWorker.kt | 8 +- .../main/res/drawable/ic_shortcut_search.xml | 11 ++ .../main/res/drawable/ic_shortcut_task.xml | 11 ++ .../main/res/drawable/ic_shortcut_today.xml | 11 ++ .../main/res/drawable/ic_shortcut_week.xml | 11 ++ android/app/src/main/res/values/strings.xml | 10 ++ android/app/src/main/res/xml/shortcuts.xml | 56 ++++++ 20 files changed, 669 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt create mode 100644 android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt create mode 100644 android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt create mode 100644 android/app/src/main/res/drawable/ic_shortcut_search.xml create mode 100644 android/app/src/main/res/drawable/ic_shortcut_task.xml create mode 100644 android/app/src/main/res/drawable/ic_shortcut_today.xml create mode 100644 android/app/src/main/res/drawable/ic_shortcut_week.xml create mode 100644 android/app/src/main/res/xml/shortcuts.xml diff --git a/README.md b/README.md index 664bbee..d43e0dc 100644 --- a/README.md +++ b/README.md @@ -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 дней после семестра). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4c05ef1..19779c2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + + + + diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt new file mode 100644 index 0000000..6875a88 --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/Launch.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt index c390af8..7077e62 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/MainActivity.kt @@ -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(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, diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt index b25c8ed..8ce089c 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ScheduleApp.kt @@ -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) } } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt index 27117a2..9bf392a 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/data/Settings.kt @@ -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 = 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>(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 -> diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt new file mode 100644 index 0000000..b4a05c2 --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/DayAlerts.kt @@ -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(), + ) + } +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt new file mode 100644 index 0000000..7dea042 --- /dev/null +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/LiveLesson.kt @@ -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) + + 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() + } + } + } +} diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt index 15b1ba2..19368e5 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/notify/Reminders.kt @@ -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() } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt index 06bc099..8c4ad61 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/Common.kt @@ -161,6 +161,8 @@ fun 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 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)) } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt index d5c8fb7..872b599 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/MainViewModel.kt @@ -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()) diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt index 7c0f4a8..3f8dbaa 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/PlansScreen.kt @@ -125,6 +125,13 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) { val prefs = settings?.plans ?: PlansPrefs() var editing by remember { mutableStateOf(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?>(null) } var target by remember { mutableStateOf(null) } diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt index 691a768..615de3a 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/ui/SettingsScreen.kt @@ -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 ?: "Группа не выбрана") diff --git a/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt b/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt index eead016..be9e835 100644 --- a/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt +++ b/android/app/src/main/kotlin/org/xtimms/psu/schedule/work/SyncWorker.kt @@ -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) diff --git a/android/app/src/main/res/drawable/ic_shortcut_search.xml b/android/app/src/main/res/drawable/ic_shortcut_search.xml new file mode 100644 index 0000000..6058180 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_search.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_task.xml b/android/app/src/main/res/drawable/ic_shortcut_task.xml new file mode 100644 index 0000000..db794cb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_task.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_today.xml b/android/app/src/main/res/drawable/ic_shortcut_today.xml new file mode 100644 index 0000000..fb5cf19 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_today.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_shortcut_week.xml b/android/app/src/main/res/drawable/ic_shortcut_week.xml new file mode 100644 index 0000000..ebcd4d4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_shortcut_week.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e5a719c..b4a9bc9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,6 +5,16 @@ Текущая и следующая пара вашей группы Перед парой Изменения в расписании + Поездки и переносы + Текущая пара + Сегодня + Пары на сегодня + Неделя + Вся неделя + Найти аудиторию + Найти аудиторию + Новое задание + Новое задание Следующая пара Когда следующая пара, что это и в какой аудитории Пара diff --git a/android/app/src/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..9d0b2e9 --- /dev/null +++ b/android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + +