feat(#171): full-screen reminder alerts per calendar
This commit is contained in:
1 parent
fa999f8cf0
commit
c52c6c54cc
22 files changed
+1005
-46
No files matched your search
@@ -182,6 +182,7 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation("de.jeanlucmakiola.floret:glance")
|
||||
implementation("de.jeanlucmakiola.floret:ring")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -41,10 +41,20 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
|
||||
<!-- WorkManager declares this for network constraints, which the app doesn't use (#448). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
|
||||
|
||||
<!--
|
||||
Full-screen reminder alerts (#171), opt-in per calendar. The alert screen
|
||||
opens over the lock screen through a full-screen intent; "Ring until
|
||||
dismissed" rings from a foreground service (systemExempted, covered by
|
||||
the exact-alarm permission above) with its own wake lock and vibration.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
@@ -322,6 +332,24 @@
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The full-screen reminder alert (#171): over the lock screen, out of
|
||||
recents, in a task of its own so it never brings the app with it. -->
|
||||
<activity
|
||||
android:name=".ui.reminders.ReminderAlertActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:showWhenLocked="true"
|
||||
android:taskAffinity=""
|
||||
android:turnScreenOn="true" />
|
||||
|
||||
<!-- Rings a "Ring until dismissed" reminder (#171). systemExempted: an
|
||||
app holding an exact-alarm permission continuing an alarm. -->
|
||||
<service
|
||||
android:name=".data.reminders.ReminderRingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="systemExempted" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.parseAlertStyles
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.toStoredAlertStyles
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -167,6 +170,7 @@ class SettingsPrefs @Inject constructor(
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
|
||||
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
|
||||
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
|
||||
val reminderAlertStyles: Flow<Map<Long, ReminderAlertStyle>> = prefs.flow(SettingsKeys.reminderAlertStyles)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -278,6 +282,10 @@ class SettingsPrefs @Inject constructor(
|
||||
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
|
||||
}
|
||||
|
||||
suspend fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
prefs.update(SettingsKeys.reminderAlertStyles) { it + (calendarId to style) }
|
||||
}
|
||||
|
||||
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
|
||||
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
|
||||
|
||||
@@ -605,6 +613,10 @@ object SettingsKeys {
|
||||
*/
|
||||
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
|
||||
|
||||
/** Per-calendar alert styles (#171); a calendar absent from the map uses plain notifications. */
|
||||
val reminderAlertStyles: Pref<Map<Long, ReminderAlertStyle>> =
|
||||
nullableStringPref("reminder_alert_styles").map(::parseAlertStyles) { it.toStoredAlertStyles() }
|
||||
|
||||
/**
|
||||
* Whether to offer a custom event colour on calendars that publish no colour
|
||||
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What the full-screen alerts (#171) share within the process: the reminder
|
||||
* [ReminderRingService] is ringing for, and the reminders just dismissed or
|
||||
* snoozed, so an alert screen showing one closes whichever way it was handled.
|
||||
*/
|
||||
@Singleton
|
||||
class FullScreenAlerts @Inject constructor() {
|
||||
|
||||
/** Set by [ReminderRingService] for as long as it rings. */
|
||||
@Volatile
|
||||
var ringing: ReminderAlert? = null
|
||||
|
||||
private val handledKeys = MutableSharedFlow<Long>(
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** The [ReminderAlert.key]s of reminders as they are dismissed or snoozed. */
|
||||
val handled: SharedFlow<Long> = handledKeys.asSharedFlow()
|
||||
|
||||
fun markHandled(alert: ReminderAlert) {
|
||||
handledKeys.tryEmit(alert.key)
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -13,8 +13,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||
* the internal re-show when a snooze elapses. All three are app-internal
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
* intents (notification action buttons, the full-screen alert's buttons and
|
||||
* our own [ReminderSnoozeScheduler] alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
@@ -74,6 +74,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
/** Not handled here either: the slot of the full-screen alert's own intent (#171). */
|
||||
const val ACTION_ALERT_SCREEN = "de.jeanlucmakiola.calendula.reminders.ALERT_SCREEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
@@ -94,16 +97,21 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
putAlert(alert)
|
||||
}
|
||||
|
||||
/** Carries [alert] as extras, for [alertFrom] to read back. */
|
||||
fun Intent.putAlert(alert: ReminderAlert): Intent = apply {
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
@@ -115,6 +123,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
ACTION_ALERT_SCREEN -> 5
|
||||
else -> 0
|
||||
}
|
||||
return (identity(alert, action).toInt() shl 3) + actionOffset
|
||||
@@ -123,7 +132,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private fun identity(alert: ReminderAlert, action: String): Long =
|
||||
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
|
||||
@@ -6,6 +6,9 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
/** The single channel every reminder posted on before they moved per calendar (#145). */
|
||||
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
|
||||
|
||||
/** The silent channel ringing full-screen alerts post on (#171); outside the per-calendar namespace. */
|
||||
const val RING_CHANNEL_ID = "reminders_ring"
|
||||
|
||||
private const val CHANNEL_PREFIX = "reminders.calendar."
|
||||
private const val GROUP_PREFIX = "reminders.account."
|
||||
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
@@ -9,6 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -72,10 +74,41 @@ class ReminderChannels @Inject constructor(
|
||||
* has not got to it yet. Null when the calendar no longer exists. Should the
|
||||
* calendar lookup fail or come back empty, the channel is still created,
|
||||
* plainly named and ungrouped, for the next sync to name and file — a
|
||||
* reminder is never lost to its channel.
|
||||
* reminder is never lost to its channel. A ringing [style] posts on the
|
||||
* shared ring channel instead, once the calendar is known to exist.
|
||||
*/
|
||||
@Synchronized
|
||||
fun channelFor(calendarId: Long): String? {
|
||||
fun channelFor(calendarId: Long, style: ReminderAlertStyle = ReminderAlertStyle.Notification): String? {
|
||||
val calendarChannel = calendarChannelFor(calendarId) ?: return null
|
||||
return if (style == ReminderAlertStyle.FullScreenRing) ringChannel() else calendarChannel
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a ringing full-screen alert (#171) posts on, shared by every
|
||||
* calendar. Silent: the ring service owns the sound and vibration, so the
|
||||
* channel adds neither. High importance keeps the heads-up a denied
|
||||
* full-screen intent degrades to.
|
||||
*/
|
||||
private fun ringChannel(): String {
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(RING_CHANNEL_ID) == null) {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
RING_CHANNEL_ID,
|
||||
context.getString(R.string.reminder_ring_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_ring_channel_description)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
},
|
||||
)
|
||||
}
|
||||
return RING_CHANNEL_ID
|
||||
}
|
||||
|
||||
private fun calendarChannelFor(calendarId: Long): String? {
|
||||
val id = reminderChannelId(calendarId)
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(id) != null) return id
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
@@ -16,7 +17,11 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.reminders.ReminderAlertActivity
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
@@ -28,7 +33,8 @@ import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per occurrence on its calendar's channel
|
||||
* ([ReminderChannels], #145). Tapping opens the event's detail screen.
|
||||
* ([ReminderChannels], #145), or as a full-screen alert where the calendar
|
||||
* asks for one (#171). Tapping opens the event's detail screen.
|
||||
*
|
||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
||||
@@ -42,6 +48,7 @@ class ReminderNotifier @Inject constructor(
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val channels: ReminderChannels,
|
||||
private val alerts: FullScreenAlerts,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -62,16 +69,54 @@ class ReminderNotifier @Inject constructor(
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/** Post [alert], unless its calendar is silenced or gone. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
/**
|
||||
* Post [alert] in its calendar's alert style (#171), unless the calendar is
|
||||
* silenced or gone. [quiet] posts it as a plain notification that makes no
|
||||
* sound: a ring handing it back to the shade.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert, quiet: Boolean = false) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
val channelId = try {
|
||||
channels.channelFor(alert.calendarId)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
|
||||
null
|
||||
} ?: return
|
||||
val style = if (quiet) {
|
||||
ReminderAlertStyle.Notification
|
||||
} else {
|
||||
settingsPrefs.reminderAlertStyles.first().alertStyleFor(alert.calendarId)
|
||||
}
|
||||
val channelId = channelFor(alert, style) ?: return
|
||||
val title = displayTitle(alert)
|
||||
val text = contentText(alert)
|
||||
if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
val ringing = build(alert, channelId, title, text, fullScreen = true, ringing = true, quiet = false)
|
||||
if (ReminderRingService.start(context, alert, ringing)) {
|
||||
// An earlier reminder for this occurrence gives way to the ring.
|
||||
NotificationManagerCompat.from(context).cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
cancelLegacy(alert)
|
||||
return
|
||||
}
|
||||
// Android refused the ring: still full-screen, sounding once on the calendar's channel.
|
||||
val fallback = channelFor(alert, ReminderAlertStyle.FullScreen) ?: return
|
||||
notify(alert, build(alert, fallback, title, text, fullScreen = true, ringing = false, quiet = false))
|
||||
return
|
||||
}
|
||||
// A ring for this occurrence from before its calendar's style changed.
|
||||
if (alerts.ringing?.occurrenceKey == alert.occurrenceKey) ReminderRingService.stop(context)
|
||||
notify(
|
||||
alert,
|
||||
build(alert, channelId, title, text, fullScreen = style.isFullScreen, ringing = false, quiet = quiet),
|
||||
)
|
||||
}
|
||||
|
||||
private fun channelFor(alert: ReminderAlert, style: ReminderAlertStyle): String? = try {
|
||||
channels.channelFor(alert.calendarId, style)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun contentText(alert: ReminderAlert): String =
|
||||
listOfNotNull(timeText(alert), alert.location).joinToString(" · ")
|
||||
|
||||
/** When [alert]'s occurrence happens, as its notification words it. */
|
||||
suspend fun timeText(alert: ReminderAlert): String {
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
@@ -79,7 +124,7 @@ class ReminderNotifier @Inject constructor(
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
return reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
@@ -92,19 +137,34 @@ class ReminderNotifier @Inject constructor(
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder's notification. A [ringing] one belongs to [ReminderRingService]:
|
||||
* ongoing, opening the alert screen, and dismissed by a swipe, since from
|
||||
* Android 14 a swipe can take it away while the ring goes on. [fullScreen]
|
||||
* adds the alert screen as a full-screen intent where Android allows it,
|
||||
* and degrades to a heads-up where it does not.
|
||||
*/
|
||||
private fun build(
|
||||
alert: ReminderAlert,
|
||||
channelId: String,
|
||||
title: String,
|
||||
text: String,
|
||||
fullScreen: Boolean,
|
||||
ringing: Boolean,
|
||||
quiet: Boolean,
|
||||
): Notification {
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setWhen(alert.beginMillis)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
.setCategory(if (ringing) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(if (ringing) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
|
||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.setContentIntent(if (ringing) alertScreenIntent(alert) else detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
context.getString(R.string.reminder_action_snooze),
|
||||
@@ -115,7 +175,19 @@ class ReminderNotifier @Inject constructor(
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.build()
|
||||
if (ringing) {
|
||||
builder.setOngoing(true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setDeleteIntent(actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS))
|
||||
} else {
|
||||
builder.setAutoCancel(true).setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
}
|
||||
if (quiet) builder.setSilent(true)
|
||||
if (fullScreen && context.canUseFullScreenIntent()) builder.setFullScreenIntent(alertScreenIntent(alert), true)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun notify(alert: ReminderAlert, notification: Notification) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
try {
|
||||
@@ -136,10 +208,20 @@ class ReminderNotifier @Inject constructor(
|
||||
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
// Posted before reminders shared a notification, under the reminder's own key.
|
||||
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
if (alerts.ringing?.key == alert.key) ReminderRingService.stop(context)
|
||||
alerts.markHandled(alert)
|
||||
}
|
||||
|
||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean {
|
||||
val ringing = alerts.ringing?.takeIf { it.occurrenceKey == alert.occurrenceKey }
|
||||
if (ringing != null) return ringing.key != alert.key
|
||||
return shownKey(alert).let { it != null && it != alert.key }
|
||||
}
|
||||
|
||||
/** Whether [alert] itself is still on screen, as a notification or ringing. */
|
||||
fun isShowing(alert: ReminderAlert): Boolean =
|
||||
alerts.ringing?.key == alert.key || shownKey(alert) == alert.key
|
||||
|
||||
/** The key of the reminder showing for [alert]'s occurrence, if any. */
|
||||
private fun shownKey(alert: ReminderAlert): Long? {
|
||||
@@ -176,6 +258,13 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun alertScreenIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_ALERT_SCREEN),
|
||||
ReminderAlertActivity.intent(context, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.floret.ring.RingVibrator
|
||||
import de.jeanlucmakiola.floret.ring.RingWakeLock
|
||||
import de.jeanlucmakiola.floret.ring.TonePlayer
|
||||
import de.jeanlucmakiola.floret.ring.startRingForeground
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Rings a reminder whose calendar asks for "Ring until dismissed" (#171): the
|
||||
* alarm sound looped with vibration, for as long as the reminder is neither
|
||||
* dismissed nor snoozed, up to [RING_TIMEOUT_MILLIS]. Then it goes quiet and
|
||||
* hands the reminder back to the shade as an ordinary notification.
|
||||
*
|
||||
* One reminder rings at a time. A new one takes over, and the one it replaces
|
||||
* stays in the shade, silently, unless it was for the same occurrence.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderRingService : Service() {
|
||||
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val tones by lazy { TonePlayer(this) }
|
||||
private val vibrator by lazy { RingVibrator(this) }
|
||||
private val wakeLock by lazy { RingWakeLock(this, WAKE_LOCK_TAG, RING_TIMEOUT_MILLIS) }
|
||||
|
||||
private var ringing: Job? = null
|
||||
private var timeout: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val alert = intent?.let(ReminderActionReceiver::alertFrom)
|
||||
val notification = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION, Notification::class.java) }
|
||||
if (alert == null || notification == null) {
|
||||
if (alerts.ringing == null) stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Before anything else: the platform gives a foreground service a few
|
||||
// seconds to post its notification.
|
||||
startRingForeground(RING_NOTIFICATION_ID, notification)
|
||||
val previous = alerts.ringing
|
||||
if (previous?.key == alert.key && timeout?.isActive == true) return START_REDELIVER_INTENT
|
||||
alerts.ringing = alert
|
||||
if (previous != null && previous.occurrenceKey != alert.occurrenceKey) {
|
||||
scope.launch { notifier.post(previous, quiet = true) }
|
||||
}
|
||||
ring()
|
||||
// Re-delivered rather than dropped, so a ring the system killed comes back.
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseRing()
|
||||
alerts.ringing = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun ring() {
|
||||
releaseRing()
|
||||
wakeLock.acquire()
|
||||
// Vibration first: the sound may take a moment to open.
|
||||
vibrator.start()
|
||||
ringing = scope.launch { tones.play(scope, ringSources()) }
|
||||
timeout = scope.launch {
|
||||
delay(RING_TIMEOUT_MILLIS)
|
||||
giveUp()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rung long enough: back to the shade quietly, so the reminder is not lost. */
|
||||
private suspend fun giveUp() {
|
||||
timeout = null
|
||||
val alert = alerts.ringing
|
||||
releaseRing()
|
||||
alerts.ringing = null
|
||||
if (alert != null) {
|
||||
try {
|
||||
notifier.post(alert, quiet = true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not hand the reminder of event ${alert.eventId} back", e)
|
||||
}
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun releaseRing() {
|
||||
ringing?.cancel()
|
||||
ringing = null
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
/** The device's alarm sound, then the built-in ones; none opening leaves the vibration. */
|
||||
private fun ringSources() = listOfNotNull(
|
||||
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderRingService"
|
||||
private const val EXTRA_NOTIFICATION = "notification"
|
||||
private const val WAKE_LOCK_TAG = "calendula:reminder-ring"
|
||||
|
||||
/** Distinct from the reminders' own id, which is shared under per-occurrence tags. */
|
||||
private const val RING_NOTIFICATION_ID = 2
|
||||
|
||||
/** How long a reminder rings unanswered before it goes quiet. */
|
||||
const val RING_TIMEOUT_MILLIS = 10L * 60 * 1000
|
||||
|
||||
/**
|
||||
* Rings [alert] under [notification]. False when Android refused to
|
||||
* start the service from where the reminder fired.
|
||||
*/
|
||||
fun start(context: Context, alert: ReminderAlert, notification: Notification): Boolean {
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.putAlert(alert)
|
||||
.putExtra(EXTRA_NOTIFICATION, notification)
|
||||
return try {
|
||||
RingWakeLock.handOff(context, WAKE_LOCK_TAG)
|
||||
context.startForegroundService(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not start the ring for event ${alert.eventId}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** `stopService`, not a command: a command is a start, which can throw from the background. */
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, ReminderRingService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
/** How a calendar's reminders get the user's attention (#171). */
|
||||
enum class ReminderAlertStyle(val storageValue: String) {
|
||||
/** An ordinary notification on the calendar's channel. */
|
||||
Notification("notification"),
|
||||
|
||||
/** A full-screen alert that sounds the calendar's notification sound once and stays until handled. */
|
||||
FullScreen("full_screen"),
|
||||
|
||||
/** A full-screen alert that rings and vibrates like an alarm until handled. */
|
||||
FullScreenRing("full_screen_ring"),
|
||||
;
|
||||
|
||||
val isFullScreen: Boolean get() = this != Notification
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String): ReminderAlertStyle? = entries.firstOrNull { it.storageValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** [calendarId]'s alert style; a calendar without one uses plain notifications. */
|
||||
fun Map<Long, ReminderAlertStyle>.alertStyleFor(calendarId: Long): ReminderAlertStyle =
|
||||
this[calendarId] ?: ReminderAlertStyle.Notification
|
||||
|
||||
/**
|
||||
* Parse `id=style` entries joined by `;`. Malformed entries and unknown styles
|
||||
* are dropped, so a garbled value degrades to plain notifications.
|
||||
*/
|
||||
fun parseAlertStyles(stored: String?): Map<Long, ReminderAlertStyle> =
|
||||
stored?.split(";").orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split("=")
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val style = ReminderAlertStyle.fromStorage(parts[1].trim()) ?: return@mapNotNull null
|
||||
calendarId to style
|
||||
}.toMap()
|
||||
|
||||
/** The stored form of [parseAlertStyles]; plain-notification entries are left out. */
|
||||
fun Map<Long, ReminderAlertStyle>.toStoredAlertStyles(): String =
|
||||
entries.filter { it.value != ReminderAlertStyle.Notification }
|
||||
.sortedBy { it.key }
|
||||
.joinToString(";") { (id, style) -> "$id=${style.storageValue}" }
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.FullScreenAlerts
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import de.jeanlucmakiola.floret.ring.showOverLockScreen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The full-screen reminder alert (#171), opened by the reminder's full-screen
|
||||
* intent over the lock screen, or from its heads-up while the phone is in use.
|
||||
* It stays until the reminder is dismissed or snoozed — here, from the
|
||||
* notification, or by the ring giving up — and back does not close it.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderAlertActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarDataSource: CalendarDataSource
|
||||
|
||||
@Inject @IoDispatcher
|
||||
lateinit var io: CoroutineDispatcher
|
||||
|
||||
private var state by mutableStateOf<ReminderAlertUiState>(ReminderAlertUiState.Loading)
|
||||
private var alert: ReminderAlert? = null
|
||||
private var loading: Job? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
showOverLockScreen()
|
||||
enableEdgeToEdge()
|
||||
show(intent)
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
alerts.handled.collect { key -> if (key == alert?.key) finish() }
|
||||
}
|
||||
}
|
||||
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
|
||||
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
|
||||
setContent {
|
||||
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
|
||||
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
|
||||
val soften by settingsPrefs.softenCalendarColors.collectAsStateWithLifecycle(true)
|
||||
CalendulaTheme(
|
||||
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
) {
|
||||
CompositionLocalProvider(LocalSoftenColors provides soften) {
|
||||
BackHandler(enabled = true) { /* Handled by Dismiss or Snooze, not by leaving. */ }
|
||||
when (val current = state) {
|
||||
ReminderAlertUiState.Loading ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
is ReminderAlertUiState.Showing -> ReminderAlertScreen(
|
||||
state = current,
|
||||
onSnooze = { act(ReminderActionReceiver.ACTION_SNOOZE) },
|
||||
onDismiss = { act(ReminderActionReceiver.ACTION_DISMISS) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
show(intent)
|
||||
}
|
||||
|
||||
/** Tapped from a stale notification, or handled while this window was away. */
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val current = alert
|
||||
if (current != null && !notifier.isShowing(current)) finish()
|
||||
}
|
||||
|
||||
private fun show(intent: Intent) {
|
||||
val next = ReminderActionReceiver.alertFrom(intent)
|
||||
if (next == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
alert = next
|
||||
loading?.cancel()
|
||||
state = ReminderAlertUiState.Loading
|
||||
loading = lifecycleScope.launch {
|
||||
val calendar = try {
|
||||
withContext(io) { calendarDataSource.calendars().firstOrNull { it.id == next.calendarId } }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// The alert still shows, only without its calendar.
|
||||
Log.w(TAG, "Could not read the calendar of event ${next.eventId}", e)
|
||||
null
|
||||
}
|
||||
state = ReminderAlertUiState.Showing(
|
||||
title = next.title,
|
||||
time = notifier.timeText(next),
|
||||
location = next.location?.takeIf { it.isNotBlank() },
|
||||
calendarName = calendar?.displayName,
|
||||
calendarColor = calendar?.color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun act(action: String) {
|
||||
val current = alert ?: return
|
||||
sendBroadcast(ReminderActionReceiver.intent(this, action, current))
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderAlertActivity"
|
||||
|
||||
fun intent(context: Context, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderAlertActivity::class.java)
|
||||
.putAlert(alert)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Event
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Snooze
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.ring.RingBadge
|
||||
|
||||
/**
|
||||
* The alert itself, after the incoming-call screen: the calendar's pulsing
|
||||
* badge, what and when in the middle, and the two ways out at the bottom,
|
||||
* large enough to hit half awake.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ReminderAlertScreen(
|
||||
state: ReminderAlertUiState.Showing,
|
||||
onSnooze: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
state.calendarName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
CalendarBadge(state.calendarColor)
|
||||
Spacer(Modifier.size(32.dp))
|
||||
Text(
|
||||
text = state.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = state.time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
state.location?.let { location ->
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocationOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1.5f))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().widthIn(max = 480.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_snooze),
|
||||
icon = Icons.Outlined.Snooze,
|
||||
tonal = true,
|
||||
onClick = onSnooze,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_dismiss),
|
||||
icon = Icons.Rounded.Close,
|
||||
tonal = false,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar's colour as the badge fill, or the theme's primary container without one. */
|
||||
@Composable
|
||||
private fun CalendarBadge(color: Int?) {
|
||||
if (color == null) {
|
||||
RingBadge(icon = Icons.Outlined.Event, size = 120.dp)
|
||||
return
|
||||
}
|
||||
val fill = eventFill(color, LocalFloretDarkTheme.current, LocalSoftenColors.current)
|
||||
RingBadge(
|
||||
icon = Icons.Outlined.Event,
|
||||
containerColor = fill,
|
||||
contentColor = eventInk(fill, alpha = 1f),
|
||||
size = 120.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun AlertButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
tonal: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val height = ButtonDefaults.LargeContainerHeight
|
||||
val content: @Composable () -> Unit = {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.iconSizeFor(height)))
|
||||
Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(height)))
|
||||
Text(label, style = ButtonDefaults.textStyleFor(height), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val buttonModifier = modifier.heightIn(min = height)
|
||||
val padding = ButtonDefaults.contentPaddingFor(height)
|
||||
if (tonal) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
} else {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
sealed interface ReminderAlertUiState {
|
||||
/** The calendar is still being read; the surface is already up. */
|
||||
data object Loading : ReminderAlertUiState
|
||||
|
||||
data class Showing(
|
||||
/** Raw title; blank shows as "(no title)". */
|
||||
val title: String,
|
||||
val time: String,
|
||||
val location: String?,
|
||||
/** Null when the calendar could not be read. */
|
||||
val calendarName: String?,
|
||||
val calendarColor: Int?,
|
||||
) : ReminderAlertUiState
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -47,11 +49,14 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import de.jeanlucmakiola.floret.ring.openFullScreenIntentSettings
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -89,6 +94,9 @@ internal fun NotificationsScreen(
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var alertStylePicker by remember { mutableStateOf<Long?>(null) }
|
||||
var alertSoundPicker by remember { mutableStateOf<Long?>(null) }
|
||||
val canFullScreen = rememberCanUseFullScreenIntent()
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -217,6 +225,10 @@ internal fun NotificationsScreen(
|
||||
notifying = notifying,
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
canFullScreen = canFullScreen,
|
||||
onEditAlertStyle = { alertStylePicker = calendar.id },
|
||||
onEditAlertSound = { alertSoundPicker = calendar.id },
|
||||
onAllowFullScreen = { context.openFullScreenIntentSettings() },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
)
|
||||
@@ -227,6 +239,51 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
alertStylePicker?.let { calendarId ->
|
||||
val current = state.reminderAlertStyles.alertStyleFor(calendarId)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
options = listOf(ReminderAlertStyle.Notification, ReminderAlertStyle.FullScreen),
|
||||
selected = if (current.isFullScreen) ReminderAlertStyle.FullScreen else ReminderAlertStyle.Notification,
|
||||
label = { alertStyleLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it.isFullScreen) {
|
||||
R.string.settings_alert_style_full_screen_summary
|
||||
} else {
|
||||
R.string.settings_alert_style_notification_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { picked ->
|
||||
if (picked.isFullScreen != current.isFullScreen) {
|
||||
viewModel.setCalendarAlertStyle(calendarId, picked)
|
||||
// Guide the user straight to the switch Android 14+ may hold back.
|
||||
if (picked.isFullScreen && !canFullScreen) context.openFullScreenIntentSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = { alertStylePicker = null },
|
||||
)
|
||||
}
|
||||
alertSoundPicker?.let { calendarId ->
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
options = listOf(ReminderAlertStyle.FullScreen, ReminderAlertStyle.FullScreenRing),
|
||||
selected = state.reminderAlertStyles.alertStyleFor(calendarId),
|
||||
label = { alertSoundLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it == ReminderAlertStyle.FullScreenRing) {
|
||||
R.string.settings_alert_sound_ring_summary
|
||||
} else {
|
||||
R.string.settings_alert_sound_once_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { viewModel.setCalendarAlertStyle(calendarId, it) },
|
||||
onDismiss = { alertSoundPicker = null },
|
||||
)
|
||||
}
|
||||
if (showSnooze) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
@@ -318,11 +375,17 @@ private fun CalendarNotificationRows(
|
||||
notifying: Boolean,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
canFullScreen: Boolean,
|
||||
onEditAlertStyle: () -> Unit,
|
||||
onEditAlertSound: () -> Unit,
|
||||
onAllowFullScreen: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
onEditOverride: (OverrideTarget) -> Unit,
|
||||
) {
|
||||
val managed = calendar.id in state.managedCalendarIds
|
||||
val takesOverrides = calendar.canModifyContents && !managed
|
||||
val alertStyle = state.reminderAlertStyles.alertStyleFor(calendar.id)
|
||||
val needsPermission = alertStyle.isFullScreen && !canFullScreen
|
||||
Column {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_calendar_notifications),
|
||||
@@ -334,10 +397,34 @@ private fun CalendarNotificationRows(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(R.string.settings_calendar_notification_sound_hint),
|
||||
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
val lastAlertRow = !(managed || takesOverrides)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
summary = alertStyleLabel(alertStyle),
|
||||
position = if (lastAlertRow && !alertStyle.isFullScreen) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertStyle,
|
||||
)
|
||||
if (alertStyle.isFullScreen) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
summary = alertSoundLabel(alertStyle),
|
||||
position = if (lastAlertRow && !needsPermission) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertSound,
|
||||
)
|
||||
}
|
||||
if (needsPermission) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_full_screen_permission),
|
||||
summary = stringResource(R.string.settings_full_screen_permission_hint),
|
||||
position = if (lastAlertRow) Position.Bottom else Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onAllowFullScreen,
|
||||
)
|
||||
}
|
||||
if (managed) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
@@ -399,6 +486,20 @@ private fun calendarOverrideSummary(
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun alertStyleLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style.isFullScreen) R.string.settings_alert_style_full_screen else R.string.settings_alert_style_notification,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun alertSoundLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style == ReminderAlertStyle.FullScreenRing) R.string.settings_alert_sound_ring else R.string.settings_alert_sound_once,
|
||||
)
|
||||
|
||||
/** Whether Android lets full-screen alerts open, re-read on every `ON_RESUME` like [rememberBatteryOptimizationExempt]. */
|
||||
@Composable
|
||||
private fun rememberCanUseFullScreenIntent(): Boolean = rememberOnResume { it.canUseFullScreenIntent() }
|
||||
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
@@ -407,20 +508,22 @@ private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean = rememberOnResume(::isIgnoringBatteryOptimizations)
|
||||
|
||||
/** [read], re-read on every `ON_RESUME` so a change made in system settings shows up at once. */
|
||||
@Composable
|
||||
private fun rememberOnResume(read: (Context) -> Boolean): Boolean {
|
||||
val context = LocalContext.current
|
||||
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||
var value by remember { mutableStateOf(read(context)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
exempt = isIgnoringBatteryOptimizations(context)
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_RESUME) value = read(context)
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
return exempt
|
||||
return value
|
||||
}
|
||||
|
||||
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -102,6 +103,8 @@ data class SettingsUiState(
|
||||
val reminderCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Calendars whose reminders Calendula doesn't post (#272). */
|
||||
val mutedReminderCalendarIds: Set<Long> = emptySet(),
|
||||
/** Per-calendar alert styles (#171); absent means a plain notification. */
|
||||
val reminderAlertStyles: Map<Long, ReminderAlertStyle> = emptyMap(),
|
||||
/**
|
||||
* Whether the event-colour picker is offered on calendars that publish no
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
|
||||
@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -131,6 +132,7 @@ class SettingsViewModel @Inject constructor(
|
||||
writableCalendars = calendars.filter { it.canModifyContents },
|
||||
reminderCalendars = calendars,
|
||||
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
|
||||
reminderAlertStyles = this[SettingsKeys.reminderAlertStyles],
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -370,6 +372,8 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) = save { setCalendarAlertStyle(calendarId, style) }
|
||||
|
||||
/** [calendarId]'s reminder channel, created if needed so its system settings can open (#340). */
|
||||
suspend fun reminderChannelFor(calendarId: Long): String? = onChannels { channelFor(calendarId) }
|
||||
|
||||
|
||||
@@ -376,6 +376,8 @@
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
<string name="reminder_ring_channel_name">Ringing reminders</string>
|
||||
<string name="reminder_ring_channel_description">Full-screen reminders that ring until you dismiss them. Calendula plays their sound itself.</string>
|
||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||
<string name="reminder_day_today">Today</string>
|
||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||
@@ -738,6 +740,18 @@
|
||||
<string name="settings_notification_sound">Notification sound</string>
|
||||
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
|
||||
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
|
||||
<string name="settings_alert_style">Alert style</string>
|
||||
<string name="settings_alert_style_notification">Notification</string>
|
||||
<string name="settings_alert_style_notification_summary">A notification that pops up and waits in the shade</string>
|
||||
<string name="settings_alert_style_full_screen">Full-screen alert</string>
|
||||
<string name="settings_alert_style_full_screen_summary">Fills the screen, even on the lock screen, until you dismiss or snooze it</string>
|
||||
<string name="settings_alert_sound">Full-screen sound</string>
|
||||
<string name="settings_alert_sound_once">Play sound once</string>
|
||||
<string name="settings_alert_sound_once_summary">This calendar\'s notification sound, once</string>
|
||||
<string name="settings_alert_sound_ring">Ring until dismissed</string>
|
||||
<string name="settings_alert_sound_ring_summary">Your alarm sound and vibration, like an alarm</string>
|
||||
<string name="settings_full_screen_permission">Allow full-screen alerts</string>
|
||||
<string name="settings_full_screen_permission_hint">Android blocks Calendula\'s full-screen alerts, so they only pop up as notifications. Tap to allow them.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -614,6 +615,18 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar alert styles default to notifications and persist per calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.reminderAlertStyles.first()).isEmpty()
|
||||
prefs.setCalendarAlertStyle(7L, ReminderAlertStyle.FullScreenRing)
|
||||
prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.FullScreen)
|
||||
prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.Notification)
|
||||
assertThat(prefs.reminderAlertStyles.first()).containsExactly(7L, ReminderAlertStyle.FullScreenRing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderAlertStyleTest {
|
||||
|
||||
@Test
|
||||
fun `styles round-trip through their stored form`() {
|
||||
val styles = mapOf(9L to ReminderAlertStyle.FullScreenRing, 3L to ReminderAlertStyle.FullScreen)
|
||||
|
||||
assertThat(parseAlertStyles(styles.toStoredAlertStyles())).isEqualTo(styles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain notifications are not stored`() {
|
||||
val stored = mapOf(1L to ReminderAlertStyle.Notification, 2L to ReminderAlertStyle.FullScreen).toStoredAlertStyles()
|
||||
|
||||
assertThat(stored).isEqualTo("2=full_screen")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbled entries are dropped, the rest survive`() {
|
||||
assertThat(parseAlertStyles("x=full_screen;4=loud;5;6=full_screen_ring"))
|
||||
.containsExactly(6L, ReminderAlertStyle.FullScreenRing)
|
||||
assertThat(parseAlertStyles(null)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar without a style uses notifications`() {
|
||||
val styles = mapOf(1L to ReminderAlertStyle.FullScreen)
|
||||
|
||||
assertThat(styles.alertStyleFor(1L)).isEqualTo(ReminderAlertStyle.FullScreen)
|
||||
assertThat(styles.alertStyleFor(2L)).isEqualTo(ReminderAlertStyle.Notification)
|
||||
assertThat(ReminderAlertStyle.Notification.isFullScreen).isFalse()
|
||||
assertThat(ReminderAlertStyle.FullScreenRing.isFullScreen).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -409,6 +409,24 @@ off switch (#272): the scan plans nothing for those calendars (so they arm no
|
||||
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
|
||||
for snoozes.
|
||||
|
||||
**Full-screen alerts (#171).** Settings → Notifications gives each calendar an
|
||||
alert style (`SettingsPrefs.reminderAlertStyles`): a plain notification, a
|
||||
full-screen alert that sounds once, or one that rings until handled. Only
|
||||
`ReminderNotifier.post` reads it, and `ReminderChannels.channelFor(id, style)`
|
||||
picks the channel. "Sound once" stays on the calendar's own channel, so its
|
||||
sound is still the one chosen in system settings, and adds a full-screen intent
|
||||
to `ReminderAlertActivity`. "Ring" starts `ReminderRingService`, which goes
|
||||
foreground on the shared silent `reminders_ring` channel and loops the alarm
|
||||
sound with vibration through floret-kit's `ring` module — the same player
|
||||
Clockula's alarms use. One reminder rings at a time; a newer one takes over and
|
||||
hands the older back to the shade quietly, as does a ring left unanswered for
|
||||
ten minutes. Dismiss and Snooze, from the screen, the notification or a swipe,
|
||||
all go through `ReminderActionReceiver`, so the ring stops and the screen
|
||||
closes (`FullScreenAlerts`) however the reminder was handled. When Android 14+
|
||||
withholds `USE_FULL_SCREEN_INTENT`, the intent is left off and the alert is a
|
||||
heads-up; Settings shows a row leading to the system switch. When Android
|
||||
refuses the ring service, the reminder falls back to sounding once.
|
||||
|
||||
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
||||
Keeping both would double-post wherever the provider works, and Etar's way out —
|
||||
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
||||
|
||||
+1
-3
@@ -96,9 +96,7 @@ The settings screen contains links to the source code, the licence, the issue tr
|
||||
- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM`: deliver reminders at the exact time, including after snoozing.
|
||||
- `RECEIVE_BOOT_COMPLETED`: re-register pending reminders after a restart.
|
||||
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: only to open the system dialog for the “Reliable delivery” setting.
|
||||
- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE`: required by the Android system component used for scheduled background work (WorkManager).
|
||||
|
||||
`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists. It does **not** permit using one. Without `INTERNET`, no connection is possible.
|
||||
- `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, `WAKE_LOCK`, `VIBRATE`: only for calendars you set to full-screen alerts — showing the alert over the lock screen and, with “Ring until dismissed”, ringing and vibrating until you dismiss or snooze it.
|
||||
|
||||
## 7. Distribution channels
|
||||
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 730c9eb25a...86b8e60862.
Reference in new issue
Block a user