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:
@@ -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 дней после семестра).
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,15 +106,21 @@ object Reminders {
|
||||
putExtra("text", text)
|
||||
putExtra("id", slot)
|
||||
}
|
||||
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.toEpochMilli(), pending)
|
||||
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.toEpochMilli() - WINDOW / 2, WINDOW, pending)
|
||||
}
|
||||
slot++
|
||||
}
|
||||
alarms.setWindow(AlarmManager.RTC_WAKEUP, at - WINDOW / 2, WINDOW, pending)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 ?: "Группа не выбрана")
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_search.xml
Normal file
11
android/app/src/main/res/drawable/ic_shortcut_search.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_task.xml
Normal file
11
android/app/src/main/res/drawable/ic_shortcut_task.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_today.xml
Normal file
11
android/app/src/main/res/drawable/ic_shortcut_today.xml
Normal 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>
|
||||
11
android/app/src/main/res/drawable/ic_shortcut_week.xml
Normal file
11
android/app/src/main/res/drawable/ic_shortcut_week.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
56
android/app/src/main/res/xml/shortcuts.xml
Normal file
56
android/app/src/main/res/xml/shortcuts.xml
Normal 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>
|
||||
Reference in New Issue
Block a user