From 955dccff2b0528b51056bf8a77eb399b87462e1e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 14 Nov 2026 02:25:34 +0100 Subject: [PATCH] feat(#145): show calendars blocked in system settings, one-time Samsung category hint --- .../calendula/data/prefs/SettingsPrefs.kt | 9 +++ .../data/reminders/ReminderChannelPlan.kt | 22 ++++++ .../data/reminders/ReminderChannels.kt | 21 +++++ .../ui/settings/NotificationSettings.kt | 78 ++++++++++++++++++- .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 15 ++++ app/src/main/res/values/strings.xml | 4 + .../data/reminders/ReminderChannelPlanTest.kt | 16 ++++ docs/ARCHITECTURE.md | 4 +- 9 files changed, 166 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index bd98b6ab..13e6c7b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -167,6 +167,7 @@ class SettingsPrefs @Inject constructor( val perCalendarEventDuration: Flow> = prefs.flow(SettingsKeys.perCalendarEventDuration) val remindersEnabled: Flow = prefs.flow(SettingsKeys.remindersEnabled) val mutedReminderCalendarIds: Flow> = prefs.flow(SettingsKeys.mutedReminderCalendarIds) + val notificationCategoryHintDismissed: Flow = prefs.flow(SettingsKeys.notificationCategoryHintDismissed) val allowColorOnUnsupportedCalendars: Flow = prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars) val defaultReminderMinutes: Flow> = 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> = 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 = 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt index 1f070222..f75eb262 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlan.kt @@ -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, + appEnabled: Boolean, + channels: Map, + blockedGroupIds: Set, +): Set { + if (!appEnabled) return calendarIds.toSet() + return calendarIds.filterTo(HashSet()) { id -> + val channel = channels[reminderChannelId(id)] ?: return@filterTo false + channel.blocked || channel.groupId in blockedGroupIds + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt index 2f80a55c..95fbfff5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannels.kt @@ -102,6 +102,21 @@ class ReminderChannels @Inject constructor( return id } + /** What the system currently lets through for [calendarIds]' reminders (#145). */ + fun status(calendarIds: Collection): 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, existing: List, @@ -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 = emptySet(), +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt index b87b4b9c..85d5b064 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/NotificationSettings.kt @@ -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()) } 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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 43a8edce..1a0cc2fc 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -102,6 +102,8 @@ data class SettingsUiState( val reminderCalendars: List = emptyList(), /** Calendars whose reminders Calendula doesn't post (#272). */ val mutedReminderCalendarIds: Set = 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). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 5dae73ee..ce2e2adf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -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 = _channelStatus.asStateFlow() + + fun refreshChannelStatus(calendarIds: Collection) { + 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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc92c56e..3e7b8d77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -737,6 +737,10 @@ Notifications off Notification sound Sound, vibration and pop-ups for each calendar, in system settings + Blocked in system settings + Blocked in system settings — tap to allow + On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on. + Open settings Sound, vibration and pop-ups for this calendar, in system settings Default (%1$s) Reliable delivery diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlanTest.kt index e32fa3a4..84d0c21d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderChannelPlanTest.kt @@ -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" } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4822bd47..8f0a77ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 —