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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P4 Stream P — tests for [NotificationPreferencesStore].
|
||||||
|
*
|
||||||
|
* Robolectric-backed because the store both reads/writes DataStore and
|
||||||
|
* rewrites Android [android.app.NotificationChannel] importance when a
|
||||||
|
* category toggle flips.
|
||||||
|
*
|
||||||
|
* Mirrors the iOS behaviour in
|
||||||
|
* `iosApp/iosApp/Profile/NotificationPreferencesView.swift` where each
|
||||||
|
* category toggle persists independently and a master switch can disable
|
||||||
|
* everything in one tap.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||||
|
class NotificationPreferencesStoreTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var store: NotificationPreferencesStore
|
||||||
|
private lateinit var manager: NotificationManager
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
// Clean slate
|
||||||
|
manager.notificationChannels.forEach { manager.deleteNotificationChannel(it.id) }
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
store = NotificationPreferencesStore(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = runTest {
|
||||||
|
store.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaults_allCategoriesEnabled() = runTest {
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.RESIDENCE_INVITE))
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.SUBSCRIPTION))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaults_masterToggleEnabled() = runTest {
|
||||||
|
assertTrue(store.isAllEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setCategoryEnabled_false_persists() = runTest {
|
||||||
|
store.setCategoryEnabled(NotificationChannels.TASK_REMINDER, false)
|
||||||
|
assertFalse(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
|
||||||
|
// Other categories untouched
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setCategoryEnabled_roundtrip_trueThenFalseThenTrue() = runTest {
|
||||||
|
val id = NotificationChannels.TASK_OVERDUE
|
||||||
|
store.setCategoryEnabled(id, false)
|
||||||
|
assertFalse(store.isCategoryEnabled(id))
|
||||||
|
store.setCategoryEnabled(id, true)
|
||||||
|
assertTrue(store.isCategoryEnabled(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setAllEnabled_false_disablesEveryCategory() = runTest {
|
||||||
|
store.setAllEnabled(false)
|
||||||
|
assertFalse(store.isAllEnabled())
|
||||||
|
assertFalse(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
|
||||||
|
assertFalse(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
|
||||||
|
assertFalse(store.isCategoryEnabled(NotificationChannels.RESIDENCE_INVITE))
|
||||||
|
assertFalse(store.isCategoryEnabled(NotificationChannels.SUBSCRIPTION))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setAllEnabled_true_reenablesEveryCategory() = runTest {
|
||||||
|
store.setAllEnabled(false)
|
||||||
|
store.setAllEnabled(true)
|
||||||
|
assertTrue(store.isAllEnabled())
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_REMINDER))
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.TASK_OVERDUE))
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.RESIDENCE_INVITE))
|
||||||
|
assertTrue(store.isCategoryEnabled(NotificationChannels.SUBSCRIPTION))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observePreferences_emitsInitialSnapshot() = runTest {
|
||||||
|
val snapshot = store.observePreferences().first()
|
||||||
|
assertEquals(true, snapshot[NotificationChannels.TASK_REMINDER])
|
||||||
|
assertEquals(true, snapshot[NotificationChannels.TASK_OVERDUE])
|
||||||
|
assertEquals(true, snapshot[NotificationChannels.RESIDENCE_INVITE])
|
||||||
|
assertEquals(true, snapshot[NotificationChannels.SUBSCRIPTION])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observePreferences_emitsUpdatesOnChange() = runTest {
|
||||||
|
// Collect first two distinct emissions: the initial snapshot and the
|
||||||
|
// update produced by flipping TASK_OVERDUE.
|
||||||
|
val collected = mutableListOf<Map<String, Boolean>>()
|
||||||
|
val job = launch {
|
||||||
|
store.observePreferences().take(2).toList(collected)
|
||||||
|
}
|
||||||
|
// Let the first emission land, then flip the flag.
|
||||||
|
yield()
|
||||||
|
store.setCategoryEnabled(NotificationChannels.TASK_OVERDUE, false)
|
||||||
|
job.join()
|
||||||
|
|
||||||
|
assertEquals(2, collected.size)
|
||||||
|
assertEquals(true, collected[0][NotificationChannels.TASK_OVERDUE])
|
||||||
|
assertEquals(false, collected[1][NotificationChannels.TASK_OVERDUE])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setCategoryEnabled_false_rewritesChannelImportanceToNone() = runTest {
|
||||||
|
// Precondition: TASK_REMINDER was created with IMPORTANCE_DEFAULT.
|
||||||
|
val before = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
|
||||||
|
assertEquals(NotificationManager.IMPORTANCE_DEFAULT, before.importance)
|
||||||
|
|
||||||
|
store.setCategoryEnabled(NotificationChannels.TASK_REMINDER, false)
|
||||||
|
|
||||||
|
val after = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
|
||||||
|
assertEquals(NotificationManager.IMPORTANCE_NONE, after.importance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setAllEnabled_false_silencesAllChannels() = runTest {
|
||||||
|
store.setAllEnabled(false)
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
NotificationChannels.TASK_REMINDER,
|
||||||
|
NotificationChannels.TASK_OVERDUE,
|
||||||
|
NotificationChannels.RESIDENCE_INVITE,
|
||||||
|
NotificationChannels.SUBSCRIPTION,
|
||||||
|
).forEach { id ->
|
||||||
|
val channel = manager.getNotificationChannel(id)
|
||||||
|
assertEquals(
|
||||||
|
"Channel $id should be IMPORTANCE_NONE after master toggle off",
|
||||||
|
NotificationManager.IMPORTANCE_NONE,
|
||||||
|
channel.importance,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -626,6 +626,21 @@
|
|||||||
<string name="notifications_change_time">Change</string>
|
<string name="notifications_change_time">Change</string>
|
||||||
<string name="notifications_select_time">Select Notification Time</string>
|
<string name="notifications_select_time">Select Notification Time</string>
|
||||||
|
|
||||||
|
<!-- P4 Stream P — per-category toggles -->
|
||||||
|
<string name="notifications_master_title">All notifications</string>
|
||||||
|
<string name="notifications_master_desc">Turn every category on or off in one tap</string>
|
||||||
|
<string name="notifications_categories_section">Categories</string>
|
||||||
|
<string name="notifications_category_task_reminder">Task reminders</string>
|
||||||
|
<string name="notifications_category_task_reminder_desc">Upcoming and due-soon reminders</string>
|
||||||
|
<string name="notifications_category_task_overdue">Overdue tasks</string>
|
||||||
|
<string name="notifications_category_task_overdue_desc">Alerts when a task is past its due date</string>
|
||||||
|
<string name="notifications_category_residence_invite">Residence invites</string>
|
||||||
|
<string name="notifications_category_residence_invite_desc">Invitations to join a shared residence</string>
|
||||||
|
<string name="notifications_category_subscription">Subscription updates</string>
|
||||||
|
<string name="notifications_category_subscription_desc">Billing and plan status changes</string>
|
||||||
|
<string name="notifications_open_system_settings">Open system settings</string>
|
||||||
|
<string name="notifications_system_settings_desc">Fine-tune sounds, badges, and Do Not Disturb behaviour in Android settings</string>
|
||||||
|
|
||||||
<!-- Common -->
|
<!-- Common -->
|
||||||
<string name="common_save">Save</string>
|
<string name="common_save">Save</string>
|
||||||
<string name="common_cancel">Cancel</string>
|
<string name="common_cancel">Cancel</string>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-specific bits for [NotificationPreferencesScreen].
|
||||||
|
*
|
||||||
|
* Only Android has real behaviour here:
|
||||||
|
* - `rememberNotificationCategoriesController` wires the screen to the
|
||||||
|
* DataStore-backed `NotificationPreferencesStore` and rewrites the
|
||||||
|
* matching [android.app.NotificationChannel] importance on every
|
||||||
|
* toggle.
|
||||||
|
* - `openAppNotificationSettings` launches
|
||||||
|
* `Settings.ACTION_APP_NOTIFICATION_SETTINGS`.
|
||||||
|
*
|
||||||
|
* On every non-Android target the controller is `null` and the
|
||||||
|
* system-settings shortcut is a no-op — the screen hides the "Open
|
||||||
|
* system settings" button when the callback is `null`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun rememberNotificationCategoriesController(): NotificationCategoriesController?
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
expect fun rememberOpenAppNotificationSettings(): (() -> Unit)?
|
||||||
@@ -10,27 +10,62 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||||
|
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||||
import com.tt.honeyDue.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import com.tt.honeyDue.util.DateUtils
|
import com.tt.honeyDue.util.DateUtils
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoryKeys
|
||||||
import com.tt.honeyDue.viewmodel.NotificationPreferencesViewModel
|
import com.tt.honeyDue.viewmodel.NotificationPreferencesViewModel
|
||||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
|
||||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
|
||||||
import honeydue.composeapp.generated.resources.*
|
import honeydue.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification preferences screen — Android-first.
|
||||||
|
*
|
||||||
|
* Parity target: `iosApp/iosApp/Profile/NotificationPreferencesView.swift`.
|
||||||
|
*
|
||||||
|
* Rendered sections (top to bottom):
|
||||||
|
* 1. Decorative header card.
|
||||||
|
* 2. Per-category section (new, P4 Stream P). One row per channel id in
|
||||||
|
* [com.tt.honeyDue.notifications.NotificationChannels]:
|
||||||
|
* - task_reminder
|
||||||
|
* - task_overdue
|
||||||
|
* - residence_invite
|
||||||
|
* - subscription
|
||||||
|
* Each switch persists to DataStore via
|
||||||
|
* [NotificationCategoriesController] and rewrites the matching
|
||||||
|
* Android `NotificationChannel` importance to NONE when off.
|
||||||
|
* 3. Master "All notifications" toggle (writes all four categories in
|
||||||
|
* one tap).
|
||||||
|
* 4. Server-backed task / other / email sections (legacy preferences,
|
||||||
|
* unchanged — these still call the REST API).
|
||||||
|
* 5. "Open system settings" button linking to
|
||||||
|
* `Settings.ACTION_APP_NOTIFICATION_SETTINGS`.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationPreferencesScreen(
|
fun NotificationPreferencesScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
viewModel: NotificationPreferencesViewModel = viewModel { NotificationPreferencesViewModel() }
|
viewModel: NotificationPreferencesViewModel = viewModel { NotificationPreferencesViewModel() },
|
||||||
) {
|
) {
|
||||||
val preferencesState by viewModel.preferencesState.collectAsState()
|
val preferencesState by viewModel.preferencesState.collectAsState()
|
||||||
val updateState by viewModel.updateState.collectAsState()
|
val categoryState by viewModel.categoryState.collectAsState()
|
||||||
|
|
||||||
|
// Platform-specific wiring: Android provides real controller + settings
|
||||||
|
// launcher; every other target returns null and the matching section
|
||||||
|
// is hidden.
|
||||||
|
val categoriesController = rememberNotificationCategoriesController()
|
||||||
|
val openSystemSettings = rememberOpenAppNotificationSettings()
|
||||||
|
|
||||||
|
// Legacy server-backed local state
|
||||||
var taskDueSoon by remember { mutableStateOf(true) }
|
var taskDueSoon by remember { mutableStateOf(true) }
|
||||||
var taskOverdue by remember { mutableStateOf(true) }
|
var taskOverdue by remember { mutableStateOf(true) }
|
||||||
var taskCompleted by remember { mutableStateOf(true) }
|
var taskCompleted by remember { mutableStateOf(true) }
|
||||||
@@ -40,29 +75,29 @@ fun NotificationPreferencesScreen(
|
|||||||
var dailyDigest by remember { mutableStateOf(true) }
|
var dailyDigest by remember { mutableStateOf(true) }
|
||||||
var emailTaskCompleted by remember { mutableStateOf(true) }
|
var emailTaskCompleted by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
// Custom notification times (local hours)
|
|
||||||
var taskDueSoonHour by remember { mutableStateOf<Int?>(null) }
|
var taskDueSoonHour by remember { mutableStateOf<Int?>(null) }
|
||||||
var taskOverdueHour by remember { mutableStateOf<Int?>(null) }
|
var taskOverdueHour by remember { mutableStateOf<Int?>(null) }
|
||||||
var warrantyExpiringHour by remember { mutableStateOf<Int?>(null) }
|
var warrantyExpiringHour by remember { mutableStateOf<Int?>(null) }
|
||||||
var dailyDigestHour by remember { mutableStateOf<Int?>(null) }
|
var dailyDigestHour by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
// Time picker dialog states
|
|
||||||
var showTaskDueSoonTimePicker by remember { mutableStateOf(false) }
|
var showTaskDueSoonTimePicker by remember { mutableStateOf(false) }
|
||||||
var showTaskOverdueTimePicker by remember { mutableStateOf(false) }
|
var showTaskOverdueTimePicker by remember { mutableStateOf(false) }
|
||||||
var showDailyDigestTimePicker by remember { mutableStateOf(false) }
|
var showDailyDigestTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Default local hours when user first enables custom time
|
val defaultTaskDueSoonLocalHour = 14
|
||||||
val defaultTaskDueSoonLocalHour = 14 // 2 PM local
|
val defaultTaskOverdueLocalHour = 9
|
||||||
val defaultTaskOverdueLocalHour = 9 // 9 AM local
|
val defaultDailyDigestLocalHour = 8
|
||||||
val defaultDailyDigestLocalHour = 8 // 8 AM local
|
|
||||||
|
|
||||||
// Track screen view and load preferences on first render
|
// Attach per-category controller (Android only) and load initial state.
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(categoriesController) {
|
||||||
PostHogAnalytics.screen(AnalyticsEvents.NOTIFICATION_SETTINGS_SCREEN_SHOWN)
|
PostHogAnalytics.screen(AnalyticsEvents.NOTIFICATION_SETTINGS_SCREEN_SHOWN)
|
||||||
viewModel.loadPreferences()
|
viewModel.loadPreferences()
|
||||||
|
if (categoriesController != null) {
|
||||||
|
viewModel.attachCategoriesController(categoriesController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state when preferences load
|
// Sync legacy server prefs into local state when they land.
|
||||||
LaunchedEffect(preferencesState) {
|
LaunchedEffect(preferencesState) {
|
||||||
if (preferencesState is ApiResult.Success) {
|
if (preferencesState is ApiResult.Success) {
|
||||||
val prefs = (preferencesState as ApiResult.Success).data
|
val prefs = (preferencesState as ApiResult.Success).data
|
||||||
@@ -75,38 +110,41 @@ fun NotificationPreferencesScreen(
|
|||||||
dailyDigest = prefs.dailyDigest
|
dailyDigest = prefs.dailyDigest
|
||||||
emailTaskCompleted = prefs.emailTaskCompleted
|
emailTaskCompleted = prefs.emailTaskCompleted
|
||||||
|
|
||||||
// Load custom notification times (convert from UTC to local)
|
prefs.taskDueSoonHour?.let { taskDueSoonHour = DateUtils.utcHourToLocal(it) }
|
||||||
prefs.taskDueSoonHour?.let { utcHour ->
|
prefs.taskOverdueHour?.let { taskOverdueHour = DateUtils.utcHourToLocal(it) }
|
||||||
taskDueSoonHour = DateUtils.utcHourToLocal(utcHour)
|
prefs.warrantyExpiringHour?.let { warrantyExpiringHour = DateUtils.utcHourToLocal(it) }
|
||||||
}
|
prefs.dailyDigestHour?.let { dailyDigestHour = DateUtils.utcHourToLocal(it) }
|
||||||
prefs.taskOverdueHour?.let { utcHour ->
|
|
||||||
taskOverdueHour = DateUtils.utcHourToLocal(utcHour)
|
|
||||||
}
|
|
||||||
prefs.warrantyExpiringHour?.let { utcHour ->
|
|
||||||
warrantyExpiringHour = DateUtils.utcHourToLocal(utcHour)
|
|
||||||
}
|
|
||||||
prefs.dailyDigestHour?.let { utcHour ->
|
|
||||||
dailyDigestHour = DateUtils.utcHourToLocal(utcHour)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val masterEnabled = remember(categoryState) {
|
||||||
|
NotificationCategoriesController.computeMasterState(categoryState)
|
||||||
}
|
}
|
||||||
|
|
||||||
WarmGradientBackground {
|
WarmGradientBackground {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(Res.string.notifications_title), fontWeight = FontWeight.SemiBold) },
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.notifications_title),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
|
Icon(
|
||||||
|
Icons.Default.ArrowBack,
|
||||||
|
contentDescription = stringResource(Res.string.common_back),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
containerColor = Color.Transparent,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
}
|
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -114,45 +152,126 @@ fun NotificationPreferencesScreen(
|
|||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
|
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
) {
|
) {
|
||||||
// Header
|
// Header
|
||||||
OrganicCard(
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(OrganicSpacing.xl),
|
.padding(OrganicSpacing.xl),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
) {
|
) {
|
||||||
OrganicIconContainer(
|
OrganicIconContainer(
|
||||||
icon = Icons.Default.Notifications,
|
icon = Icons.Default.Notifications,
|
||||||
size = 60.dp
|
size = 60.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.notifications_preferences),
|
stringResource(Res.string.notifications_preferences),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.notifications_choose),
|
stringResource(Res.string.notifications_choose),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Master toggle (P4 Stream P) — only shown when we have a real
|
||||||
|
// controller (Android). On other platforms the section is hidden.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
if (categoriesController != null) {
|
||||||
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
NotificationToggleRow(
|
||||||
|
title = stringResource(Res.string.notifications_master_title),
|
||||||
|
description = stringResource(Res.string.notifications_master_desc),
|
||||||
|
icon = Icons.Default.NotificationsActive,
|
||||||
|
iconTint = MaterialTheme.colorScheme.primary,
|
||||||
|
checked = masterEnabled,
|
||||||
|
onCheckedChange = { viewModel.toggleMaster(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.notifications_categories_section),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||||
|
)
|
||||||
|
|
||||||
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column {
|
||||||
|
CategoryRows.forEachIndexed { index, cat ->
|
||||||
|
NotificationToggleRow(
|
||||||
|
title = stringResource(cat.titleRes),
|
||||||
|
description = stringResource(cat.descRes),
|
||||||
|
icon = cat.icon,
|
||||||
|
iconTint = cat.tint(),
|
||||||
|
checked = categoryState[cat.channelId] ?: true,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
viewModel.toggleCategory(cat.channelId, enabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (index != CategoryRows.lastIndex) {
|
||||||
|
OrganicDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSystemSettings != null) {
|
||||||
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { openSystemSettings() }
|
||||||
|
.padding(OrganicSpacing.lg),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.notifications_open_system_settings),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.notifications_system_settings_desc),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Legacy server-backed sections (unchanged)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
when (preferencesState) {
|
when (preferencesState) {
|
||||||
is ApiResult.Loading -> {
|
is ApiResult.Loading -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(OrganicSpacing.xl),
|
.padding(OrganicSpacing.xl),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
@@ -161,53 +280,50 @@ fun NotificationPreferencesScreen(
|
|||||||
is ApiResult.Error -> {
|
is ApiResult.Error -> {
|
||||||
OrganicCard(
|
OrganicCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
accentColor = MaterialTheme.colorScheme.errorContainer
|
accentColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(OrganicSpacing.lg),
|
.padding(OrganicSpacing.lg),
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Error,
|
Icons.Default.Error,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
(preferencesState as ApiResult.Error).message,
|
(preferencesState as ApiResult.Error).message,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OrganicPrimaryButton(
|
OrganicPrimaryButton(
|
||||||
text = stringResource(Res.string.common_retry),
|
text = stringResource(Res.string.common_retry),
|
||||||
onClick = { viewModel.loadPreferences() },
|
onClick = { viewModel.loadPreferences() },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ApiResult.Success, is ApiResult.Idle -> {
|
is ApiResult.Success, is ApiResult.Idle -> {
|
||||||
// Task Notifications Section
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.notifications_task_section),
|
stringResource(Res.string.notifications_task_section),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(top = OrganicSpacing.md)
|
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||||
)
|
)
|
||||||
|
|
||||||
OrganicCard(
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column {
|
Column {
|
||||||
NotificationToggle(
|
NotificationToggleRow(
|
||||||
title = stringResource(Res.string.notifications_task_due_soon),
|
title = stringResource(Res.string.notifications_task_due_soon),
|
||||||
description = stringResource(Res.string.notifications_task_due_soon_desc),
|
description = stringResource(Res.string.notifications_task_due_soon_desc),
|
||||||
icon = Icons.Default.Schedule,
|
icon = Icons.Default.Schedule,
|
||||||
@@ -216,10 +332,8 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
taskDueSoon = it
|
taskDueSoon = it
|
||||||
viewModel.updatePreference(taskDueSoon = it)
|
viewModel.updatePreference(taskDueSoon = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time picker for Task Due Soon
|
|
||||||
if (taskDueSoon) {
|
if (taskDueSoon) {
|
||||||
NotificationTimePickerRow(
|
NotificationTimePickerRow(
|
||||||
currentHour = taskDueSoonHour,
|
currentHour = taskDueSoonHour,
|
||||||
@@ -229,15 +343,12 @@ fun NotificationPreferencesScreen(
|
|||||||
val utcHour = DateUtils.localHourToUtc(localHour)
|
val utcHour = DateUtils.localHourToUtc(localHour)
|
||||||
viewModel.updatePreference(taskDueSoonHour = utcHour)
|
viewModel.updatePreference(taskDueSoonHour = utcHour)
|
||||||
},
|
},
|
||||||
onChangeTime = { showTaskDueSoonTimePicker = true }
|
onChangeTime = { showTaskDueSoonTimePicker = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||||
|
|
||||||
OrganicDivider(
|
NotificationToggleRow(
|
||||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationToggle(
|
|
||||||
title = stringResource(Res.string.notifications_task_overdue),
|
title = stringResource(Res.string.notifications_task_overdue),
|
||||||
description = stringResource(Res.string.notifications_task_overdue_desc),
|
description = stringResource(Res.string.notifications_task_overdue_desc),
|
||||||
icon = Icons.Default.Warning,
|
icon = Icons.Default.Warning,
|
||||||
@@ -246,10 +357,8 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
taskOverdue = it
|
taskOverdue = it
|
||||||
viewModel.updatePreference(taskOverdue = it)
|
viewModel.updatePreference(taskOverdue = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time picker for Task Overdue
|
|
||||||
if (taskOverdue) {
|
if (taskOverdue) {
|
||||||
NotificationTimePickerRow(
|
NotificationTimePickerRow(
|
||||||
currentHour = taskOverdueHour,
|
currentHour = taskOverdueHour,
|
||||||
@@ -259,15 +368,12 @@ fun NotificationPreferencesScreen(
|
|||||||
val utcHour = DateUtils.localHourToUtc(localHour)
|
val utcHour = DateUtils.localHourToUtc(localHour)
|
||||||
viewModel.updatePreference(taskOverdueHour = utcHour)
|
viewModel.updatePreference(taskOverdueHour = utcHour)
|
||||||
},
|
},
|
||||||
onChangeTime = { showTaskOverdueTimePicker = true }
|
onChangeTime = { showTaskOverdueTimePicker = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||||
|
|
||||||
OrganicDivider(
|
NotificationToggleRow(
|
||||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationToggle(
|
|
||||||
title = stringResource(Res.string.notifications_task_completed),
|
title = stringResource(Res.string.notifications_task_completed),
|
||||||
description = stringResource(Res.string.notifications_task_completed_desc),
|
description = stringResource(Res.string.notifications_task_completed_desc),
|
||||||
icon = Icons.Default.CheckCircle,
|
icon = Icons.Default.CheckCircle,
|
||||||
@@ -276,14 +382,11 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
taskCompleted = it
|
taskCompleted = it
|
||||||
viewModel.updatePreference(taskCompleted = it)
|
viewModel.updatePreference(taskCompleted = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||||
|
|
||||||
OrganicDivider(
|
NotificationToggleRow(
|
||||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationToggle(
|
|
||||||
title = stringResource(Res.string.notifications_task_assigned),
|
title = stringResource(Res.string.notifications_task_assigned),
|
||||||
description = stringResource(Res.string.notifications_task_assigned_desc),
|
description = stringResource(Res.string.notifications_task_assigned_desc),
|
||||||
icon = Icons.Default.PersonAdd,
|
icon = Icons.Default.PersonAdd,
|
||||||
@@ -292,12 +395,11 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
taskAssigned = it
|
taskAssigned = it
|
||||||
viewModel.updatePreference(taskAssigned = it)
|
viewModel.updatePreference(taskAssigned = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time picker dialogs
|
|
||||||
if (showTaskDueSoonTimePicker) {
|
if (showTaskDueSoonTimePicker) {
|
||||||
HourPickerDialog(
|
HourPickerDialog(
|
||||||
currentHour = taskDueSoonHour ?: defaultTaskDueSoonLocalHour,
|
currentHour = taskDueSoonHour ?: defaultTaskDueSoonLocalHour,
|
||||||
@@ -307,10 +409,9 @@ fun NotificationPreferencesScreen(
|
|||||||
viewModel.updatePreference(taskDueSoonHour = utcHour)
|
viewModel.updatePreference(taskDueSoonHour = utcHour)
|
||||||
showTaskDueSoonTimePicker = false
|
showTaskDueSoonTimePicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showTaskDueSoonTimePicker = false }
|
onDismiss = { showTaskDueSoonTimePicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTaskOverdueTimePicker) {
|
if (showTaskOverdueTimePicker) {
|
||||||
HourPickerDialog(
|
HourPickerDialog(
|
||||||
currentHour = taskOverdueHour ?: defaultTaskOverdueLocalHour,
|
currentHour = taskOverdueHour ?: defaultTaskOverdueLocalHour,
|
||||||
@@ -320,23 +421,20 @@ fun NotificationPreferencesScreen(
|
|||||||
viewModel.updatePreference(taskOverdueHour = utcHour)
|
viewModel.updatePreference(taskOverdueHour = utcHour)
|
||||||
showTaskOverdueTimePicker = false
|
showTaskOverdueTimePicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showTaskOverdueTimePicker = false }
|
onDismiss = { showTaskOverdueTimePicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other Notifications Section
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.notifications_other_section),
|
stringResource(Res.string.notifications_other_section),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(top = OrganicSpacing.md)
|
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||||
)
|
)
|
||||||
|
|
||||||
OrganicCard(
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column {
|
Column {
|
||||||
NotificationToggle(
|
NotificationToggleRow(
|
||||||
title = stringResource(Res.string.notifications_property_shared),
|
title = stringResource(Res.string.notifications_property_shared),
|
||||||
description = stringResource(Res.string.notifications_property_shared_desc),
|
description = stringResource(Res.string.notifications_property_shared_desc),
|
||||||
icon = Icons.Default.Home,
|
icon = Icons.Default.Home,
|
||||||
@@ -345,14 +443,11 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
residenceShared = it
|
residenceShared = it
|
||||||
viewModel.updatePreference(residenceShared = it)
|
viewModel.updatePreference(residenceShared = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||||
|
|
||||||
OrganicDivider(
|
NotificationToggleRow(
|
||||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationToggle(
|
|
||||||
title = stringResource(Res.string.notifications_warranty_expiring),
|
title = stringResource(Res.string.notifications_warranty_expiring),
|
||||||
description = stringResource(Res.string.notifications_warranty_expiring_desc),
|
description = stringResource(Res.string.notifications_warranty_expiring_desc),
|
||||||
icon = Icons.Default.Description,
|
icon = Icons.Default.Description,
|
||||||
@@ -361,14 +456,11 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
warrantyExpiring = it
|
warrantyExpiring = it
|
||||||
viewModel.updatePreference(warrantyExpiring = it)
|
viewModel.updatePreference(warrantyExpiring = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||||
|
|
||||||
OrganicDivider(
|
NotificationToggleRow(
|
||||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationToggle(
|
|
||||||
title = stringResource(Res.string.notifications_daily_digest),
|
title = stringResource(Res.string.notifications_daily_digest),
|
||||||
description = stringResource(Res.string.notifications_daily_digest_desc),
|
description = stringResource(Res.string.notifications_daily_digest_desc),
|
||||||
icon = Icons.Default.Summarize,
|
icon = Icons.Default.Summarize,
|
||||||
@@ -377,10 +469,8 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
dailyDigest = it
|
dailyDigest = it
|
||||||
viewModel.updatePreference(dailyDigest = it)
|
viewModel.updatePreference(dailyDigest = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Time picker for Daily Digest
|
|
||||||
if (dailyDigest) {
|
if (dailyDigest) {
|
||||||
NotificationTimePickerRow(
|
NotificationTimePickerRow(
|
||||||
currentHour = dailyDigestHour,
|
currentHour = dailyDigestHour,
|
||||||
@@ -390,13 +480,12 @@ fun NotificationPreferencesScreen(
|
|||||||
val utcHour = DateUtils.localHourToUtc(localHour)
|
val utcHour = DateUtils.localHourToUtc(localHour)
|
||||||
viewModel.updatePreference(dailyDigestHour = utcHour)
|
viewModel.updatePreference(dailyDigestHour = utcHour)
|
||||||
},
|
},
|
||||||
onChangeTime = { showDailyDigestTimePicker = true }
|
onChangeTime = { showDailyDigestTimePicker = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daily Digest time picker dialog
|
|
||||||
if (showDailyDigestTimePicker) {
|
if (showDailyDigestTimePicker) {
|
||||||
HourPickerDialog(
|
HourPickerDialog(
|
||||||
currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour,
|
currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour,
|
||||||
@@ -406,23 +495,20 @@ fun NotificationPreferencesScreen(
|
|||||||
viewModel.updatePreference(dailyDigestHour = utcHour)
|
viewModel.updatePreference(dailyDigestHour = utcHour)
|
||||||
showDailyDigestTimePicker = false
|
showDailyDigestTimePicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showDailyDigestTimePicker = false }
|
onDismiss = { showDailyDigestTimePicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email Notifications Section
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.notifications_email_section),
|
stringResource(Res.string.notifications_email_section),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(top = OrganicSpacing.md)
|
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||||
)
|
)
|
||||||
|
|
||||||
OrganicCard(
|
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column {
|
Column {
|
||||||
NotificationToggle(
|
NotificationToggleRow(
|
||||||
title = stringResource(Res.string.notifications_email_task_completed),
|
title = stringResource(Res.string.notifications_email_task_completed),
|
||||||
description = stringResource(Res.string.notifications_email_task_completed_desc),
|
description = stringResource(Res.string.notifications_email_task_completed_desc),
|
||||||
icon = Icons.Default.Email,
|
icon = Icons.Default.Email,
|
||||||
@@ -431,7 +517,7 @@ fun NotificationPreferencesScreen(
|
|||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
emailTaskCompleted = it
|
emailTaskCompleted = it
|
||||||
viewModel.updatePreference(emailTaskCompleted = it)
|
viewModel.updatePreference(emailTaskCompleted = it)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -444,51 +530,92 @@ fun NotificationPreferencesScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for the four per-category rows. Order matches
|
||||||
|
* [NotificationCategoryKeys.ALL].
|
||||||
|
*/
|
||||||
|
private data class NotificationCategoryRow(
|
||||||
|
val channelId: String,
|
||||||
|
val titleRes: StringResource,
|
||||||
|
val descRes: StringResource,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val tint: @Composable () -> Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val CategoryRows: List<NotificationCategoryRow> = listOf(
|
||||||
|
NotificationCategoryRow(
|
||||||
|
channelId = NotificationCategoryKeys.TASK_REMINDER,
|
||||||
|
titleRes = Res.string.notifications_category_task_reminder,
|
||||||
|
descRes = Res.string.notifications_category_task_reminder_desc,
|
||||||
|
icon = Icons.Default.Schedule,
|
||||||
|
tint = { MaterialTheme.colorScheme.tertiary },
|
||||||
|
),
|
||||||
|
NotificationCategoryRow(
|
||||||
|
channelId = NotificationCategoryKeys.TASK_OVERDUE,
|
||||||
|
titleRes = Res.string.notifications_category_task_overdue,
|
||||||
|
descRes = Res.string.notifications_category_task_overdue_desc,
|
||||||
|
icon = Icons.Default.Warning,
|
||||||
|
tint = { MaterialTheme.colorScheme.error },
|
||||||
|
),
|
||||||
|
NotificationCategoryRow(
|
||||||
|
channelId = NotificationCategoryKeys.RESIDENCE_INVITE,
|
||||||
|
titleRes = Res.string.notifications_category_residence_invite,
|
||||||
|
descRes = Res.string.notifications_category_residence_invite_desc,
|
||||||
|
icon = Icons.Default.Home,
|
||||||
|
tint = { MaterialTheme.colorScheme.primary },
|
||||||
|
),
|
||||||
|
NotificationCategoryRow(
|
||||||
|
channelId = NotificationCategoryKeys.SUBSCRIPTION,
|
||||||
|
titleRes = Res.string.notifications_category_subscription,
|
||||||
|
descRes = Res.string.notifications_category_subscription_desc,
|
||||||
|
icon = Icons.Default.Star,
|
||||||
|
tint = { MaterialTheme.colorScheme.secondary },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NotificationToggle(
|
private fun NotificationToggleRow(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
icon: ImageVector,
|
||||||
iconTint: androidx.compose.ui.graphics.Color,
|
iconTint: Color,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onCheckedChange: (Boolean) -> Unit
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(OrganicSpacing.lg),
|
.padding(OrganicSpacing.lg),
|
||||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = iconTint
|
tint = iconTint,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
colors = SwitchDefaults.colors(
|
colors = SwitchDefaults.colors(
|
||||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
checkedTrackColor = MaterialTheme.colorScheme.primary
|
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,41 +624,44 @@ private fun NotificationToggle(
|
|||||||
private fun NotificationTimePickerRow(
|
private fun NotificationTimePickerRow(
|
||||||
currentHour: Int?,
|
currentHour: Int?,
|
||||||
onSetCustomTime: () -> Unit,
|
onSetCustomTime: () -> Unit,
|
||||||
onChangeTime: () -> Unit
|
onChangeTime: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = OrganicSpacing.lg + 24.dp + OrganicSpacing.md, end = OrganicSpacing.lg, bottom = OrganicSpacing.md),
|
.padding(
|
||||||
|
start = OrganicSpacing.lg + 24.dp + OrganicSpacing.md,
|
||||||
|
end = OrganicSpacing.lg,
|
||||||
|
bottom = OrganicSpacing.md,
|
||||||
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
|
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.AccessTime,
|
imageVector = Icons.Default.AccessTime,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (currentHour != null) {
|
if (currentHour != null) {
|
||||||
Text(
|
Text(
|
||||||
text = DateUtils.formatHour(currentHour),
|
text = DateUtils.formatHour(currentHour),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.notifications_change_time),
|
text = stringResource(Res.string.notifications_change_time),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.clickable { onChangeTime() }
|
modifier = Modifier.clickable { onChangeTime() },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.notifications_set_custom_time),
|
text = stringResource(Res.string.notifications_set_custom_time),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.clickable { onSetCustomTime() }
|
modifier = Modifier.clickable { onSetCustomTime() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,7 +672,7 @@ private fun NotificationTimePickerRow(
|
|||||||
private fun HourPickerDialog(
|
private fun HourPickerDialog(
|
||||||
currentHour: Int,
|
currentHour: Int,
|
||||||
onHourSelected: (Int) -> Unit,
|
onHourSelected: (Int) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedHour by remember { mutableStateOf(currentHour) }
|
var selectedHour by remember { mutableStateOf(currentHour) }
|
||||||
|
|
||||||
@@ -551,82 +681,33 @@ private fun HourPickerDialog(
|
|||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.notifications_select_time),
|
stringResource(Res.string.notifications_select_time),
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = DateUtils.formatHour(selectedHour),
|
text = DateUtils.formatHour(selectedHour),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Hour selector with AM/PM periods
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
) {
|
) {
|
||||||
// AM hours (6 AM - 11 AM)
|
HourColumn(label = "AM", range = 6..11, selectedHour = selectedHour) {
|
||||||
Column(
|
selectedHour = it
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"AM",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
(6..11).forEach { hour ->
|
|
||||||
HourChip(
|
|
||||||
hour = hour,
|
|
||||||
isSelected = selectedHour == hour,
|
|
||||||
onClick = { selectedHour = hour }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
HourColumn(label = "PM", range = 12..17, selectedHour = selectedHour) {
|
||||||
|
selectedHour = it
|
||||||
}
|
}
|
||||||
|
HourColumn(label = "EVE", range = 18..23, selectedHour = selectedHour) {
|
||||||
// PM hours (12 PM - 5 PM)
|
selectedHour = it
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"PM",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
(12..17).forEach { hour ->
|
|
||||||
HourChip(
|
|
||||||
hour = hour,
|
|
||||||
isSelected = selectedHour == hour,
|
|
||||||
onClick = { selectedHour = hour }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evening hours (6 PM - 11 PM)
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"EVE",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
(18..23).forEach { hour ->
|
|
||||||
HourChip(
|
|
||||||
hour = hour,
|
|
||||||
isSelected = selectedHour == hour,
|
|
||||||
onClick = { selectedHour = hour }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -640,15 +721,41 @@ private fun HourPickerDialog(
|
|||||||
TextButton(onClick = onDismiss) {
|
TextButton(onClick = onDismiss) {
|
||||||
Text(stringResource(Res.string.common_cancel))
|
Text(stringResource(Res.string.common_cancel))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HourColumn(
|
||||||
|
label: String,
|
||||||
|
range: IntRange,
|
||||||
|
selectedHour: Int,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
range.forEach { hour ->
|
||||||
|
HourChip(
|
||||||
|
hour = hour,
|
||||||
|
isSelected = selectedHour == hour,
|
||||||
|
onClick = { onSelect(hour) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HourChip(
|
private fun HourChip(
|
||||||
hour: Int,
|
hour: Int,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val displayHour = when {
|
val displayHour = when {
|
||||||
hour == 0 -> "12"
|
hour == 0 -> "12"
|
||||||
@@ -662,15 +769,18 @@ private fun HourChip(
|
|||||||
.width(56.dp)
|
.width(56.dp)
|
||||||
.clickable { onClick() },
|
.clickable { onClick() },
|
||||||
shape = OrganicShapes.small,
|
shape = OrganicShapes.small,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
|
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "$displayHour $amPm",
|
text = "$displayHour $amPm",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = OrganicSpacing.xs),
|
modifier = Modifier.padding(
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
horizontal = OrganicSpacing.sm,
|
||||||
|
vertical = OrganicSpacing.xs,
|
||||||
|
),
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,88 @@ import com.tt.honeyDue.network.ApiResult
|
|||||||
import com.tt.honeyDue.network.APILayer
|
import com.tt.honeyDue.network.APILayer
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable channel-id list used by the per-category notification UI. Keep
|
||||||
|
* in lockstep with the Android `NotificationChannels` object (the same
|
||||||
|
* four ids) — the parity is enforced by
|
||||||
|
* `NotificationPreferencesScreenTest.categoryKeys_matchNotificationChannels`.
|
||||||
|
*/
|
||||||
|
object NotificationCategoryKeys {
|
||||||
|
const val TASK_REMINDER = "task_reminder"
|
||||||
|
const val TASK_OVERDUE = "task_overdue"
|
||||||
|
const val RESIDENCE_INVITE = "residence_invite"
|
||||||
|
const val SUBSCRIPTION = "subscription"
|
||||||
|
|
||||||
|
val ALL: List<String> = listOf(
|
||||||
|
TASK_REMINDER,
|
||||||
|
TASK_OVERDUE,
|
||||||
|
RESIDENCE_INVITE,
|
||||||
|
SUBSCRIPTION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-agnostic façade around the per-category notification preference
|
||||||
|
* store. The Android implementation wires `loadAll` / `setCategory` /
|
||||||
|
* `setAll` to `NotificationPreferencesStore` (DataStore-backed); tests in
|
||||||
|
* `commonTest` wire them to an in-memory fake.
|
||||||
|
*
|
||||||
|
* Kept in the viewmodel package (not `ui.screens`) so it can be referenced
|
||||||
|
* from `commonTest` without pulling in Compose types.
|
||||||
|
*/
|
||||||
|
class NotificationCategoriesController(
|
||||||
|
private val loadAll: suspend () -> Map<String, Boolean>,
|
||||||
|
private val setCategory: suspend (String, Boolean) -> Unit,
|
||||||
|
private val setAll: suspend (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
suspend fun load(): Map<String, Boolean> = loadAll()
|
||||||
|
|
||||||
|
suspend fun onCategoryToggle(channelId: String, enabled: Boolean) {
|
||||||
|
setCategory(channelId, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onMasterToggle(enabled: Boolean) {
|
||||||
|
setAll(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Compute the master toggle's visible state from the current
|
||||||
|
* per-category snapshot. Master is "on" iff every category is on
|
||||||
|
* AND the snapshot is non-empty (an empty snapshot is the
|
||||||
|
* "no data yet" state, which the UI renders as master-off).
|
||||||
|
*/
|
||||||
|
fun computeMasterState(snapshot: Map<String, Boolean>): Boolean =
|
||||||
|
snapshot.isNotEmpty() && snapshot.values.all { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing remote-preferences ViewModel (server-backed). Retained
|
||||||
|
* unchanged so the legacy Daily Digest / email prefs continue to work
|
||||||
|
* alongside the new per-category local toggles driven by
|
||||||
|
* [NotificationCategoriesController].
|
||||||
|
*/
|
||||||
class NotificationPreferencesViewModel : ViewModel() {
|
class NotificationPreferencesViewModel : ViewModel() {
|
||||||
|
|
||||||
private val _preferencesState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
private val _preferencesState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
||||||
val preferencesState: StateFlow<ApiResult<NotificationPreference>> = _preferencesState
|
val preferencesState: StateFlow<ApiResult<NotificationPreference>> = _preferencesState.asStateFlow()
|
||||||
|
|
||||||
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
private val _updateState = MutableStateFlow<ApiResult<NotificationPreference>>(ApiResult.Idle)
|
||||||
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState
|
val updateState: StateFlow<ApiResult<NotificationPreference>> = _updateState.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-category local toggle state, keyed by [NotificationCategoryKeys]
|
||||||
|
* channel ids. Backed on Android by `NotificationPreferencesStore` via
|
||||||
|
* [NotificationCategoriesController].
|
||||||
|
*/
|
||||||
|
private val _categoryState = MutableStateFlow<Map<String, Boolean>>(
|
||||||
|
NotificationCategoryKeys.ALL.associateWith { true },
|
||||||
|
)
|
||||||
|
val categoryState: StateFlow<Map<String, Boolean>> = _categoryState.asStateFlow()
|
||||||
|
|
||||||
fun loadPreferences() {
|
fun loadPreferences() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -42,7 +115,7 @@ class NotificationPreferencesViewModel : ViewModel() {
|
|||||||
taskDueSoonHour: Int? = null,
|
taskDueSoonHour: Int? = null,
|
||||||
taskOverdueHour: Int? = null,
|
taskOverdueHour: Int? = null,
|
||||||
warrantyExpiringHour: Int? = null,
|
warrantyExpiringHour: Int? = null,
|
||||||
dailyDigestHour: Int? = null
|
dailyDigestHour: Int? = null,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_updateState.value = ApiResult.Loading
|
_updateState.value = ApiResult.Loading
|
||||||
@@ -58,12 +131,11 @@ class NotificationPreferencesViewModel : ViewModel() {
|
|||||||
taskDueSoonHour = taskDueSoonHour,
|
taskDueSoonHour = taskDueSoonHour,
|
||||||
taskOverdueHour = taskOverdueHour,
|
taskOverdueHour = taskOverdueHour,
|
||||||
warrantyExpiringHour = warrantyExpiringHour,
|
warrantyExpiringHour = warrantyExpiringHour,
|
||||||
dailyDigestHour = dailyDigestHour
|
dailyDigestHour = dailyDigestHour,
|
||||||
)
|
)
|
||||||
val result = APILayer.updateNotificationPreferences(request)
|
val result = APILayer.updateNotificationPreferences(request)
|
||||||
_updateState.value = when (result) {
|
_updateState.value = when (result) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
// Update the preferences state with the new values
|
|
||||||
_preferencesState.value = ApiResult.Success(result.data)
|
_preferencesState.value = ApiResult.Success(result.data)
|
||||||
ApiResult.Success(result.data)
|
ApiResult.Success(result.data)
|
||||||
}
|
}
|
||||||
@@ -76,4 +148,41 @@ class NotificationPreferencesViewModel : ViewModel() {
|
|||||||
fun resetUpdateState() {
|
fun resetUpdateState() {
|
||||||
_updateState.value = ApiResult.Idle
|
_updateState.value = ApiResult.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Per-category (local) toggle state — wired to platform-specific store.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a [NotificationCategoriesController] and do an initial load
|
||||||
|
* so the screen can render in sync with the on-disk preferences. Safe
|
||||||
|
* to call multiple times; later calls replace the controller.
|
||||||
|
*/
|
||||||
|
fun attachCategoriesController(controller: NotificationCategoriesController) {
|
||||||
|
this.categoriesController = controller
|
||||||
|
viewModelScope.launch {
|
||||||
|
_categoryState.value = controller.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCategory(channelId: String, enabled: Boolean) {
|
||||||
|
val controller = categoriesController ?: return
|
||||||
|
// Optimistic local update so the Switch flips immediately.
|
||||||
|
_categoryState.value = _categoryState.value.toMutableMap().apply {
|
||||||
|
put(channelId, enabled)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.onCategoryToggle(channelId, enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleMaster(enabled: Boolean) {
|
||||||
|
val controller = categoriesController ?: return
|
||||||
|
_categoryState.value = NotificationCategoryKeys.ALL.associateWith { enabled }
|
||||||
|
viewModelScope.launch {
|
||||||
|
controller.onMasterToggle(enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var categoriesController: NotificationCategoriesController? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens
|
||||||
|
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoryKeys
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P4 Stream P — state-logic tests for the per-category notification UI.
|
||||||
|
*
|
||||||
|
* These mirror the iOS per-category toggles in
|
||||||
|
* `iosApp/iosApp/Profile/NotificationPreferencesView.swift` and exercise
|
||||||
|
* [NotificationCategoriesController] — a commonMain façade used by
|
||||||
|
* [NotificationPreferencesScreen] that delegates persistence to the
|
||||||
|
* Android DataStore-backed `NotificationPreferencesStore` in production
|
||||||
|
* and to a fake in these tests.
|
||||||
|
*
|
||||||
|
* We use plain kotlin.test here (no Compose UI testing) for the same
|
||||||
|
* reasons noted in ThemeSelectionScreenTest / FeatureComparisonScreenTest
|
||||||
|
* — the commonTest recomposer+Dispatchers interplay is flaky on
|
||||||
|
* iosSimulator.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class NotificationPreferencesScreenTest {
|
||||||
|
|
||||||
|
/** In-memory stand-in for NotificationPreferencesStore. */
|
||||||
|
private class FakeStore(
|
||||||
|
initial: Map<String, Boolean> = NotificationCategoryKeys.ALL.associateWith { true },
|
||||||
|
) {
|
||||||
|
val state = initial.toMutableMap()
|
||||||
|
val setCategoryCalls: MutableList<Pair<String, Boolean>> = mutableListOf()
|
||||||
|
val setAllCalls: MutableList<Boolean> = mutableListOf()
|
||||||
|
|
||||||
|
suspend fun setCategoryEnabled(id: String, enabled: Boolean) {
|
||||||
|
setCategoryCalls += (id to enabled)
|
||||||
|
state[id] = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllEnabled(enabled: Boolean) {
|
||||||
|
setAllCalls += enabled
|
||||||
|
NotificationCategoryKeys.ALL.forEach { state[it] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadAll(): Map<String, Boolean> =
|
||||||
|
NotificationCategoryKeys.ALL.associateWith { state[it] ?: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun controllerFor(store: FakeStore) = NotificationCategoriesController(
|
||||||
|
loadAll = { store.loadAll() },
|
||||||
|
setCategory = { id, v -> store.setCategoryEnabled(id, v) },
|
||||||
|
setAll = { v -> store.setAllEnabled(v) },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun categoryKeys_matchNotificationChannels() {
|
||||||
|
// Parity guard: if NotificationChannels ever adds/removes a channel,
|
||||||
|
// the category keys used by this screen must update in lockstep.
|
||||||
|
assertEquals(
|
||||||
|
listOf("task_reminder", "task_overdue", "residence_invite", "subscription"),
|
||||||
|
NotificationCategoryKeys.ALL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialState_loadsAllCategoriesEnabled() = runTest {
|
||||||
|
val store = FakeStore()
|
||||||
|
val controller = controllerFor(store)
|
||||||
|
val snapshot = controller.load()
|
||||||
|
|
||||||
|
assertEquals(4, snapshot.size)
|
||||||
|
snapshot.values.forEach { assertTrue(it, "Every category starts enabled") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toggleCategory_invokesSetCategoryEnabled() = runTest {
|
||||||
|
val store = FakeStore()
|
||||||
|
val controller = controllerFor(store)
|
||||||
|
|
||||||
|
controller.onCategoryToggle("task_reminder", false)
|
||||||
|
|
||||||
|
assertEquals(1, store.setCategoryCalls.size)
|
||||||
|
assertEquals("task_reminder" to false, store.setCategoryCalls[0])
|
||||||
|
assertFalse(store.state["task_reminder"]!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toggleDifferentCategories_isolatesUpdates() = runTest {
|
||||||
|
val store = FakeStore()
|
||||||
|
val controller = controllerFor(store)
|
||||||
|
|
||||||
|
controller.onCategoryToggle("task_overdue", false)
|
||||||
|
controller.onCategoryToggle("subscription", false)
|
||||||
|
|
||||||
|
assertFalse(store.state["task_overdue"]!!)
|
||||||
|
assertFalse(store.state["subscription"]!!)
|
||||||
|
// Untouched categories remain enabled
|
||||||
|
assertTrue(store.state["task_reminder"]!!)
|
||||||
|
assertTrue(store.state["residence_invite"]!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun masterToggle_off_invokesSetAllEnabledFalse() = runTest {
|
||||||
|
val store = FakeStore()
|
||||||
|
val controller = controllerFor(store)
|
||||||
|
|
||||||
|
controller.onMasterToggle(false)
|
||||||
|
|
||||||
|
assertEquals(listOf(false), store.setAllCalls)
|
||||||
|
assertFalse(store.state["task_reminder"]!!)
|
||||||
|
assertFalse(store.state["task_overdue"]!!)
|
||||||
|
assertFalse(store.state["residence_invite"]!!)
|
||||||
|
assertFalse(store.state["subscription"]!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun masterToggle_on_reenablesAllCategories() = runTest {
|
||||||
|
val store = FakeStore(
|
||||||
|
initial = mapOf(
|
||||||
|
"task_reminder" to false,
|
||||||
|
"task_overdue" to false,
|
||||||
|
"residence_invite" to false,
|
||||||
|
"subscription" to false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val controller = controllerFor(store)
|
||||||
|
|
||||||
|
controller.onMasterToggle(true)
|
||||||
|
|
||||||
|
assertEquals(listOf(true), store.setAllCalls)
|
||||||
|
NotificationCategoryKeys.ALL.forEach { id ->
|
||||||
|
assertTrue(store.state[id]!!, "Category $id should be re-enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeMasterState_trueWhenAllEnabled_falseOtherwise() {
|
||||||
|
val allOn = mapOf(
|
||||||
|
"task_reminder" to true,
|
||||||
|
"task_overdue" to true,
|
||||||
|
"residence_invite" to true,
|
||||||
|
"subscription" to true,
|
||||||
|
)
|
||||||
|
val oneOff = allOn.toMutableMap().apply { put("task_overdue", false) }
|
||||||
|
|
||||||
|
assertTrue(NotificationCategoriesController.computeMasterState(allOn))
|
||||||
|
assertFalse(NotificationCategoriesController.computeMasterState(oneOff))
|
||||||
|
assertFalse(NotificationCategoriesController.computeMasterState(emptyMap()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS has its own native `NotificationPreferencesView` (SwiftUI). This
|
||||||
|
* Compose screen is Android-first, so the iOS target returns `null` here
|
||||||
|
* and the screen hides the per-category block.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
actual fun rememberNotificationCategoriesController(): NotificationCategoriesController? = null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberOpenAppNotificationSettings(): (() -> Unit)? = null
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberNotificationCategoriesController(): NotificationCategoriesController? = null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberOpenAppNotificationSettings(): (() -> Unit)? = null
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberNotificationCategoriesController(): NotificationCategoriesController? = null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberOpenAppNotificationSettings(): (() -> Unit)? = null
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.tt.honeyDue.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.tt.honeyDue.viewmodel.NotificationCategoriesController
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberNotificationCategoriesController(): NotificationCategoriesController? = null
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun rememberOpenAppNotificationSettings(): (() -> Unit)? = null
|
||||||
Reference in New Issue
Block a user