P4 Stream P: NotificationPreferencesScreen expansion

Per-category toggle + master toggle + system-settings shortcut matching
iOS NotificationPreferencesView depth. DataStore-backed prefs, channel
importance rewritten to NONE when category disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:24:45 -05:00
parent 3700968d00
commit 65af40ed73
12 changed files with 1053 additions and 206 deletions

View File

@@ -0,0 +1,180 @@
package com.tt.honeyDue.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
/**
* DataStore instance backing [NotificationPreferencesStore]. Kept at file
* scope so the delegate creates exactly one instance per process, as
* required by `preferencesDataStore`.
*/
private val Context.notificationPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
name = "notification_preferences",
)
/**
* P4 Stream P — per-category notification preferences for Android.
*
* Two distinct concepts are kept in the same DataStore file:
*
* 1. Per-category Boolean flags (one key per [NotificationChannels] id).
* These back the UI switches on `NotificationPreferencesScreen`.
*
* 2. A master "all enabled" flag that, when toggled off, silences every
* category in one write.
*
* On every write, the matching Android [android.app.NotificationChannel]'s
* importance is rewritten:
*
* * enabled → restored to the original importance from
* [NotificationChannels.ensureChannels] (DEFAULT/HIGH/LOW).
* * disabled → [NotificationManager.IMPORTANCE_NONE] so the system
* silences it without requiring the user to open system
* settings.
*
* **Caveat (documented on purpose, not a bug):** Android only allows apps
* to *lower* channel importance after creation. If the user additionally
* disabled a channel via system settings, re-enabling it in our UI cannot
* raise its importance back — the user must restore it in system settings.
* The "Open system settings" button on the screen surfaces this path, and
* our DataStore flag still tracks the user's intent so the UI stays in
* sync with reality if they re-enable it later.
*
* Mirrors the iOS per-category toggle behaviour in
* `iosApp/iosApp/Profile/NotificationPreferencesView.swift`.
*/
class NotificationPreferencesStore(private val context: Context) {
private val store get() = context.notificationPreferencesDataStore
private val notificationManager: NotificationManager by lazy {
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
/** All channel ids this store manages, in display order. */
private val categoryIds: List<String> = listOf(
NotificationChannels.TASK_REMINDER,
NotificationChannels.TASK_OVERDUE,
NotificationChannels.RESIDENCE_INVITE,
NotificationChannels.SUBSCRIPTION,
)
private fun categoryKey(channelId: String) = booleanPreferencesKey("cat_$channelId")
private val masterKey = booleanPreferencesKey("master_enabled")
// ---------------------------------------------------------------------
// Reads
// ---------------------------------------------------------------------
suspend fun isCategoryEnabled(channelId: String): Boolean =
store.data.first()[categoryKey(channelId)] ?: true
suspend fun isAllEnabled(): Boolean = store.data.first()[masterKey] ?: true
/**
* Cold [Flow] that emits the full category → enabled map on every
* DataStore change. Always includes every [categoryIds] entry, even if
* it hasn't been explicitly written yet (defaults to `true`).
*/
fun observePreferences(): Flow<Map<String, Boolean>> = store.data.map { prefs ->
categoryIds.associateWith { id -> prefs[categoryKey(id)] ?: true }
}
// ---------------------------------------------------------------------
// Writes
// ---------------------------------------------------------------------
suspend fun setCategoryEnabled(channelId: String, enabled: Boolean) {
store.edit { prefs ->
prefs[categoryKey(channelId)] = enabled
// Keep the master flag coherent: if any category is disabled,
// master is false; if every category is enabled, master is true.
val everyEnabled = categoryIds.all { id ->
if (id == channelId) enabled else prefs[categoryKey(id)] ?: true
}
prefs[masterKey] = everyEnabled
}
applyChannelImportance(channelId, enabled)
}
suspend fun setAllEnabled(enabled: Boolean) {
store.edit { prefs ->
prefs[masterKey] = enabled
categoryIds.forEach { id -> prefs[categoryKey(id)] = enabled }
}
categoryIds.forEach { id -> applyChannelImportance(id, enabled) }
}
/** Remove every key owned by this store. Used on logout / test teardown. */
suspend fun clearAll() {
store.edit { prefs ->
prefs.remove(masterKey)
categoryIds.forEach { id -> prefs.remove(categoryKey(id)) }
}
}
// ---------------------------------------------------------------------
// Channel importance rewrite
// ---------------------------------------------------------------------
/**
* Map a channel id to the importance it was created with in
* [NotificationChannels]. Keep this table in sync with the `when`
* chain there.
*/
private fun defaultImportanceFor(channelId: String): Int = when (channelId) {
NotificationChannels.TASK_OVERDUE -> NotificationManager.IMPORTANCE_HIGH
NotificationChannels.SUBSCRIPTION -> NotificationManager.IMPORTANCE_LOW
NotificationChannels.TASK_REMINDER,
NotificationChannels.RESIDENCE_INVITE,
-> NotificationManager.IMPORTANCE_DEFAULT
else -> NotificationManager.IMPORTANCE_DEFAULT
}
/**
* Rewrite the channel importance. On O+ we reach for the platform
* NotificationManager API directly; on older releases channels do not
* exist and this is a no-op (legacy handling in
* [NotificationChannels.ensureChannels]).
*/
private fun applyChannelImportance(channelId: String, enabled: Boolean) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val existing = notificationManager.getNotificationChannel(channelId)
if (existing == null) {
// Channel hasn't been created yet — bail out. It will be
// created with the right importance the next time
// NotificationChannels.ensureChannels runs, and future writes
// will see it.
return
}
val targetImportance = if (enabled) {
defaultImportanceFor(channelId)
} else {
NotificationManager.IMPORTANCE_NONE
}
if (existing.importance == targetImportance) return
// Android only lets us LOWER importance via updateNotificationChannel.
// To silence → always safe (NONE < everything else).
// To re-enable (raise) → attempt the update; if the system refused
// to raise it (user disabled via system settings) the importance
// remains as-is and the user must restore via system settings.
val rewritten = NotificationChannel(existing.id, existing.name, targetImportance).apply {
description = existing.description
group = existing.group
setShowBadge(existing.canShowBadge())
}
notificationManager.createNotificationChannel(rewritten)
}
}

View File

@@ -0,0 +1,41 @@
package com.tt.honeyDue.ui.screens
import android.content.Intent
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.tt.honeyDue.notifications.NotificationPreferencesStore
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
import com.tt.honeyDue.viewmodel.NotificationCategoryKeys
@Composable
actual fun rememberNotificationCategoriesController(): NotificationCategoriesController? {
val context = LocalContext.current
return remember(context) {
val store = NotificationPreferencesStore(context.applicationContext)
NotificationCategoriesController(
loadAll = {
NotificationCategoryKeys.ALL.associateWith { id ->
store.isCategoryEnabled(id)
}
},
setCategory = { id, enabled -> store.setCategoryEnabled(id, enabled) },
setAll = { enabled -> store.setAllEnabled(enabled) },
)
}
}
@Composable
actual fun rememberOpenAppNotificationSettings(): (() -> Unit)? {
val context = LocalContext.current
return remember(context) {
{
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}