feat(#145): show calendars blocked in system settings, one-time Samsung category hint
This commit is contained in:
1 parent
fa999f8cf0
commit
955dccff2b
9 files changed
+166
-5
No files matched your search
@@ -167,6 +167,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 notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -274,6 +275,8 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
|
||||
|
||||
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
|
||||
|
||||
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
|
||||
}
|
||||
@@ -605,6 +608,12 @@ object SettingsKeys {
|
||||
*/
|
||||
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
|
||||
|
||||
/**
|
||||
* Whether the one-time hint that some skins (Samsung) start new notification
|
||||
* categories switched off has been dismissed (#145).
|
||||
*/
|
||||
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -128,3 +128,25 @@ fun planReminderChannels(
|
||||
}
|
||||
|
||||
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
|
||||
|
||||
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
|
||||
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
|
||||
|
||||
/**
|
||||
* The calendars among [calendarIds] whose reminders the system blocks: all of
|
||||
* them while the app's notifications are off, otherwise those whose channel,
|
||||
* or the group it sits in, is switched off. A calendar without a channel yet
|
||||
* isn't blocked.
|
||||
*/
|
||||
fun blockedReminderCalendars(
|
||||
calendarIds: Collection<Long>,
|
||||
appEnabled: Boolean,
|
||||
channels: Map<String, ReminderChannelState>,
|
||||
blockedGroupIds: Set<String>,
|
||||
): Set<Long> {
|
||||
if (!appEnabled) return calendarIds.toSet()
|
||||
return calendarIds.filterTo(HashSet()) { id ->
|
||||
val channel = channels[reminderChannelId(id)] ?: return@filterTo false
|
||||
channel.blocked || channel.groupId in blockedGroupIds
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,21 @@ class ReminderChannels @Inject constructor(
|
||||
return id
|
||||
}
|
||||
|
||||
/** What the system currently lets through for [calendarIds]' reminders (#145). */
|
||||
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
|
||||
val manager = manager
|
||||
val appEnabled = manager.areNotificationsEnabled()
|
||||
val blocked = blockedReminderCalendars(
|
||||
calendarIds = calendarIds,
|
||||
appEnabled = appEnabled,
|
||||
channels = manager.notificationChannels.associate {
|
||||
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
|
||||
},
|
||||
blockedGroupIds = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id },
|
||||
)
|
||||
return ReminderChannelStatus(appBlocked = !appEnabled, blockedCalendarIds = blocked)
|
||||
}
|
||||
|
||||
private fun plan(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: List<NotificationChannel>,
|
||||
@@ -190,3 +205,9 @@ class ReminderChannels @Inject constructor(
|
||||
const val TAG = "ReminderChannels"
|
||||
}
|
||||
}
|
||||
|
||||
/** Reminder notifications the system blocks, while Calendula would post them. */
|
||||
data class ReminderChannelStatus(
|
||||
val appBlocked: Boolean = false,
|
||||
val blockedCalendarIds: Set<Long> = emptySet(),
|
||||
)
|
||||
@@ -9,6 +9,16 @@ import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -92,8 +102,19 @@ internal fun NotificationsScreen(
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
|
||||
val calendarIds = state.reminderCalendars.map { it.id }
|
||||
// Re-read whenever the screen returns from the system settings it links to.
|
||||
LifecycleResumeEffect(calendarIds) {
|
||||
viewModel.refreshChannelStatus(calendarIds)
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val openCalendarSound: (Long) -> Unit = { calendarId ->
|
||||
scope.launch { context.openNotificationSettings(viewModel.reminderChannelFor(calendarId)) }
|
||||
scope.launch {
|
||||
val channelId = viewModel.reminderChannelFor(calendarId)
|
||||
// With the whole app blocked, only the app's own screen can allow it.
|
||||
context.openNotificationSettings(channelId.takeUnless { channelStatus.appBlocked })
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
@@ -102,6 +123,18 @@ internal fun NotificationsScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
|
||||
NotificationCategoryHint(
|
||||
onOpen = {
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissNotificationCategoryHint,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
checked = state.remindersEnabled,
|
||||
@@ -111,7 +144,11 @@ internal fun NotificationsScreen(
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(R.string.settings_notification_sound_hint),
|
||||
summary = if (channelStatus.appBlocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_notification_sound_hint)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = {
|
||||
@@ -192,9 +229,14 @@ internal fun NotificationsScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
val notifying = calendar.id !in state.mutedReminderCalendarIds
|
||||
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = if (notifying) null else stringResource(R.string.settings_calendar_notifications_off),
|
||||
summary = when {
|
||||
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked)
|
||||
else -> null
|
||||
},
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
@@ -215,6 +257,7 @@ internal fun NotificationsScreen(
|
||||
calendar = calendar,
|
||||
state = state,
|
||||
notifying = notifying,
|
||||
blocked = blocked,
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
@@ -316,6 +359,7 @@ private fun CalendarNotificationRows(
|
||||
calendar: CalendarSource,
|
||||
state: SettingsUiState,
|
||||
notifying: Boolean,
|
||||
blocked: Boolean,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
@@ -333,7 +377,11 @@ private fun CalendarNotificationRows(
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = stringResource(R.string.settings_calendar_notification_sound_hint),
|
||||
summary = if (blocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_calendar_notification_sound_hint)
|
||||
},
|
||||
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
@@ -371,6 +419,28 @@ private fun CalendarNotificationRows(
|
||||
}
|
||||
}
|
||||
|
||||
/** Samsung's One UI can start a new notification category switched off (#145). */
|
||||
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
|
||||
/** The one-time Samsung note, with a way into the app's notification settings. */
|
||||
@Composable
|
||||
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
|
||||
IconInfoCard(
|
||||
icon = Icons.Outlined.Info,
|
||||
iconContentDescription = null,
|
||||
iconAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_notification_category_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
|
||||
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExternalLinkIcon() {
|
||||
Icon(
|
||||
|
||||
@@ -102,6 +102,8 @@ data class SettingsUiState(
|
||||
val reminderCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Calendars whose reminders Calendula doesn't post (#272). */
|
||||
val mutedReminderCalendarIds: Set<Long> = emptySet(),
|
||||
/** Whether the Samsung notification-category hint has been dismissed (#145). */
|
||||
val notificationCategoryHintDismissed: Boolean = false,
|
||||
/**
|
||||
* Whether the event-colour picker is offered on calendars that publish no
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
|
||||
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannelStatus
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
@@ -131,6 +132,7 @@ class SettingsViewModel @Inject constructor(
|
||||
writableCalendars = calendars.filter { it.canModifyContents },
|
||||
reminderCalendars = calendars,
|
||||
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
|
||||
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -370,6 +372,19 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
|
||||
|
||||
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
|
||||
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
|
||||
|
||||
fun refreshChannelStatus(calendarIds: Collection<Long>) {
|
||||
viewModelScope.launch {
|
||||
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
|
||||
|
||||
/** [calendarId]'s reminder channel, created if needed so its system settings can open (#340). */
|
||||
suspend fun reminderChannelFor(calendarId: Long): String? = onChannels { channelFor(calendarId) }
|
||||
|
||||
|
||||
@@ -737,6 +737,10 @@
|
||||
<string name="settings_calendar_notifications_off">Notifications off</string>
|
||||
<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_notifications_blocked">Blocked in system settings</string>
|
||||
<string name="settings_notifications_blocked_hint">Blocked in system settings — tap to allow</string>
|
||||
<string name="settings_notification_category_hint">On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on.</string>
|
||||
<string name="settings_notification_category_hint_open">Open settings</string>
|
||||
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
|
||||
+16
@@ -127,6 +127,22 @@ class ReminderChannelPlanTest {
|
||||
assertThat(posted.groupsInUse).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked calendars follow app, channel and group`() {
|
||||
val channels = mapOf(
|
||||
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = "g1"),
|
||||
reminderChannelId(2) to ReminderChannelState(blocked = false, groupId = "g2"),
|
||||
reminderChannelId(3) to ReminderChannelState(blocked = false, groupId = "g1"),
|
||||
)
|
||||
val ids = listOf(1L, 2L, 3L, 4L)
|
||||
assertThat(blockedReminderCalendars(ids, appEnabled = true, channels, blockedGroupIds = setOf("g2")))
|
||||
.containsExactly(1L, 2L)
|
||||
assertThat(blockedReminderCalendars(ids, appEnabled = true, channels, blockedGroupIds = emptySet()))
|
||||
.containsExactly(1L)
|
||||
assertThat(blockedReminderCalendars(ids, appEnabled = false, channels, blockedGroupIds = emptySet()))
|
||||
.containsExactly(1L, 2L, 3L, 4L)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DESC = "Reminders"
|
||||
}
|
||||
|
||||
@@ -407,7 +407,9 @@ the system's channel screens (#340); the app never sets a sound itself. Separate
|
||||
from both, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
|
||||
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.
|
||||
for snoozes. The settings screen re-reads `ReminderChannels.status` on every
|
||||
resume and marks a calendar "Blocked in system settings" when the app, its
|
||||
channel or its account group is switched off there.
|
||||
|
||||
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 —
|
||||
|
||||
Reference in new issue
Block a user