feat(#145): show calendars blocked in system settings, one-time Samsung category hint

This commit is contained in:
makiolaj committed 2026-11-14 02:25:34 +01:00
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) }
+4
View File
@@ -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>
@@ -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"
}
+3 -1
View File
@@ -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 —