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 минут без сети.
|
- Виджет «Сегодня» (Glance): текущая и следующая пара, обновляется каждые 15 минут без сети.
|
||||||
- Напоминания перед парой за 5–30 минут — локальные будильники из сохранённого расписания, без push-сервера. С разрешением «Будильники и напоминания» приходят точно в срок, без него — в окне ±5 минут.
|
- Напоминания перед парой за 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 обновляет его, виджет и напоминания.
|
- Офлайн: последнее расписание хранится на устройстве; каждые 6 часов WorkManager обновляет его, виджет и напоминания.
|
||||||
|
|
||||||
**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).
|
**Экзамены, зачёты, консультации и защиты курсовых** попадают в «Расписание», «Календарь» и «Планы», даже если университет публикует их отдельным документом («Расписание зачетов»): `/api/timetable` (API 6) добавляет к дням семестра события сессии группы из всех документов каталога и продлевает период до последнего из них (не дальше 60 дней после семестра).
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<!-- Optional: the student can allow exact reminder times in system settings. -->
|
<!-- Optional: the student can allow exact reminder times in system settings. -->
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<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
|
<application
|
||||||
android:name=".ScheduleApp"
|
android:name=".ScheduleApp"
|
||||||
@@ -20,11 +22,16 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
@@ -66,6 +73,14 @@
|
|||||||
android:name=".notify.ReminderReceiver"
|
android:name=".notify.ReminderReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notify.DayAlertReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notify.LiveLessonReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".notify.BootReceiver"
|
android:name=".notify.BootReceiver"
|
||||||
android:exported="true">
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val vm: MainViewModel by viewModels()
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// A recreated activity (rotation) must not open the same screen again.
|
||||||
|
if (savedInstanceState == null) launch.value = Launch.of(intent)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
@@ -181,7 +191,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
label = "onboarding",
|
label = "onboarding",
|
||||||
) { first ->
|
) { first ->
|
||||||
if (first) Onboarding(vm, onDone = { onboarding = false })
|
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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@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 tab by rememberSaveable { mutableStateOf(Tab.Home) }
|
||||||
var page by rememberSaveable { mutableStateOf(if (firstRun) Page.Groups else null) }
|
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.
|
// 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 }),
|
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(
|
val menuItems = MenuItems(
|
||||||
tab = tab,
|
tab = tab,
|
||||||
page = page,
|
page = page,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class ScheduleApp : Application() {
|
|||||||
attendance = AttendanceStore(this)
|
attendance = AttendanceStore(this)
|
||||||
Reminders.createChannel(this)
|
Reminders.createChannel(this)
|
||||||
org.xtimms.psu.schedule.notify.ChangeAlerts.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)
|
SyncWorker.schedule(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ data class UserSettings(
|
|||||||
val reminderMinutes: Int = 10,
|
val reminderMinutes: Int = 10,
|
||||||
/** A notification when the background sync finds changes in the group's document. */
|
/** A notification when the background sync finds changes in the group's document. */
|
||||||
val changeAlerts: Boolean = true,
|
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 recent: List<SavedGroup> = emptyList(),
|
||||||
val display: Display = Display(),
|
val display: Display = Display(),
|
||||||
/** Lessons hidden in the open group, as [Lesson.hideKey]s. */
|
/** 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 reminders = booleanPreferencesKey("reminders")
|
||||||
val minutes = intPreferencesKey("reminder_minutes")
|
val minutes = intPreferencesKey("reminder_minutes")
|
||||||
val changeAlerts = booleanPreferencesKey("change_alerts")
|
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 recent = stringPreferencesKey("recent")
|
||||||
val colors = booleanPreferencesKey("show_colors")
|
val colors = booleanPreferencesKey("show_colors")
|
||||||
val teachers = booleanPreferencesKey("show_teachers")
|
val teachers = booleanPreferencesKey("show_teachers")
|
||||||
@@ -95,6 +114,12 @@ class Settings(private val context: Context) {
|
|||||||
reminders = this[Keys.reminders] ?: false,
|
reminders = this[Keys.reminders] ?: false,
|
||||||
reminderMinutes = this[Keys.minutes] ?: 10,
|
reminderMinutes = this[Keys.minutes] ?: 10,
|
||||||
changeAlerts = this[Keys.changeAlerts] ?: true,
|
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 {
|
recent = this[Keys.recent]?.let {
|
||||||
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
runCatching { json.decodeFromString<List<SavedGroup>>(it) }.getOrNull()
|
||||||
}.orEmpty(),
|
}.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 setReminders(on: Boolean) = context.store.edit { it[Keys.reminders] = on }
|
||||||
suspend fun setReminderMinutes(minutes: Int) = context.store.edit { it[Keys.minutes] = minutes }
|
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 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. */
|
/** Hides or shows again a recurring lesson of the open group. */
|
||||||
suspend fun setHidden(key: String, hidden: Boolean) = context.store.edit { prefs ->
|
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.hideKey
|
||||||
import org.xtimms.psu.schedule.data.withPlace
|
import org.xtimms.psu.schedule.data.withPlace
|
||||||
|
import org.xtimms.psu.schedule.data.forSubgroup
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.AlarmManager
|
import android.app.AlarmManager
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -76,13 +77,17 @@ object Reminders {
|
|||||||
var slot = 0
|
var slot = 0
|
||||||
for (day in timetable.days) {
|
for (day in timetable.days) {
|
||||||
val date = parseDate(day.date)
|
val date = parseDate(day.date)
|
||||||
// One reminder per start time: parallel subgroups share the notification.
|
val shown = day.lessons
|
||||||
for ((start, lessons) in day.lessons
|
|
||||||
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
|
.filter { settings.subgroup == null || it.subgroup == null || it.subgroup == settings.subgroup }
|
||||||
.filter { it.hideKey !in settings.hidden }
|
.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 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
|
if (at.isBefore(now) || begins.isAfter(horizon) || slot >= MAX) continue
|
||||||
val first = lessons.first()
|
val first = lessons.first()
|
||||||
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
|
val title = if (lessons.size == 1) first.subject else lessons.joinToString(" / ") { it.subject }
|
||||||
@@ -101,18 +106,24 @@ object Reminders {
|
|||||||
putExtra("text", text)
|
putExtra("text", text)
|
||||||
putExtra("id", slot)
|
putExtra("id", slot)
|
||||||
}
|
}
|
||||||
if (canBeExact(context)) {
|
alarm(context, at.toEpochMilli(), pending)
|
||||||
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)
|
|
||||||
}
|
|
||||||
slot++
|
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) {
|
fun show(context: Context, id: Int, title: String, text: String) {
|
||||||
if (!canNotify(context)) return
|
if (!canNotify(context)) return
|
||||||
val open = PendingIntent.getActivity(
|
val open = PendingIntent.getActivity(
|
||||||
@@ -151,8 +162,8 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
try {
|
try {
|
||||||
val app = context.app
|
// Reminders, day alerts and the live lesson, as after any change.
|
||||||
Reminders.reschedule(context, app.repository.timetable.value, app.settings.now())
|
org.xtimms.psu.schedule.work.SyncWorker.afterChange(context)
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ fun <T> ConnectedChoice(
|
|||||||
selected: T,
|
selected: T,
|
||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/** The check before the chosen label; times like "20:00" need the room, the fill marks it. */
|
||||||
|
mark: Boolean = true,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier.selectableGroup(),
|
modifier.selectableGroup(),
|
||||||
@@ -185,7 +187,7 @@ fun <T> ConnectedChoice(
|
|||||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (checked) {
|
if (checked && mark) {
|
||||||
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Rounded.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(6.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) }
|
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 {
|
fun setReminderMinutes(minutes: Int) = viewModelScope.launch {
|
||||||
app.settings.setReminderMinutes(minutes)
|
app.settings.setReminderMinutes(minutes)
|
||||||
SyncWorker.afterChange(getApplication())
|
SyncWorker.afterChange(getApplication())
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ fun PlansScreen(vm: MainViewModel, nav: AppNav) {
|
|||||||
val prefs = settings?.plans ?: PlansPrefs()
|
val prefs = settings?.plans ?: PlansPrefs()
|
||||||
var editing by remember { mutableStateOf<Note?>(null) }
|
var editing by remember { mutableStateOf<Note?>(null) }
|
||||||
var adding by remember { mutableStateOf(false) }
|
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 tuning by remember { mutableStateOf(false) }
|
||||||
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
var details by remember { mutableStateOf<Pair<Lesson, LocalDate>?>(null) }
|
||||||
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
var target by remember { mutableStateOf<NoteTarget?>(null) }
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ fun SettingsScreen(vm: MainViewModel, onBack: () -> Unit) {
|
|||||||
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
val changesPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
vm.setChangeAlerts(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()
|
val scroll = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
Column(Modifier.fillMaxSize().nestedScroll(scroll.nestedScrollConnection)) {
|
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("О расписании") {
|
Section("О расписании") {
|
||||||
val doc = timetable?.document
|
val doc = timetable?.document
|
||||||
Info("Документ", doc?.name ?: "Группа не выбрана")
|
Info("Документ", doc?.name ?: "Группа не выбрана")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.work.WorkManager
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import org.xtimms.psu.schedule.app
|
import org.xtimms.psu.schedule.app
|
||||||
import org.xtimms.psu.schedule.notify.ChangeAlerts
|
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.notify.Reminders
|
||||||
import org.xtimms.psu.schedule.widget.TodayWidget
|
import org.xtimms.psu.schedule.widget.TodayWidget
|
||||||
import java.util.concurrent.TimeUnit
|
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. */
|
/** Widget and reminders follow the cached timetable and settings. */
|
||||||
suspend fun afterChange(context: Context) {
|
suspend fun afterChange(context: Context) {
|
||||||
val app = context.app
|
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)
|
TodayWidget().updateAll(context)
|
||||||
NextLessonWidget().updateAll(context)
|
NextLessonWidget().updateAll(context)
|
||||||
LessonTileService.refresh(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="widget_description">Текущая и следующая пара вашей группы</string>
|
||||||
<string name="channel_reminders">Перед парой</string>
|
<string name="channel_reminders">Перед парой</string>
|
||||||
<string name="channel_changes">Изменения в расписании</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_name">Следующая пара</string>
|
||||||
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
|
<string name="next_widget_description">Когда следующая пара, что это и в какой аудитории</string>
|
||||||
<string name="tile_name">Пара</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