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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user