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_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 -->
|
||||
<string name="common_save">Save</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.ui.Alignment
|
||||
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.unit.dp
|
||||
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.ui.theme.*
|
||||
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.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
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)
|
||||
@Composable
|
||||
fun NotificationPreferencesScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: NotificationPreferencesViewModel = viewModel { NotificationPreferencesViewModel() }
|
||||
viewModel: NotificationPreferencesViewModel = viewModel { NotificationPreferencesViewModel() },
|
||||
) {
|
||||
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 taskOverdue by remember { mutableStateOf(true) }
|
||||
var taskCompleted by remember { mutableStateOf(true) }
|
||||
@@ -40,29 +75,29 @@ fun NotificationPreferencesScreen(
|
||||
var dailyDigest by remember { mutableStateOf(true) }
|
||||
var emailTaskCompleted by remember { mutableStateOf(true) }
|
||||
|
||||
// Custom notification times (local hours)
|
||||
var taskDueSoonHour by remember { mutableStateOf<Int?>(null) }
|
||||
var taskOverdueHour by remember { mutableStateOf<Int?>(null) }
|
||||
var warrantyExpiringHour by remember { mutableStateOf<Int?>(null) }
|
||||
var dailyDigestHour by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
// Time picker dialog states
|
||||
var showTaskDueSoonTimePicker by remember { mutableStateOf(false) }
|
||||
var showTaskOverdueTimePicker by remember { mutableStateOf(false) }
|
||||
var showDailyDigestTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
// Default local hours when user first enables custom time
|
||||
val defaultTaskDueSoonLocalHour = 14 // 2 PM local
|
||||
val defaultTaskOverdueLocalHour = 9 // 9 AM local
|
||||
val defaultDailyDigestLocalHour = 8 // 8 AM local
|
||||
val defaultTaskDueSoonLocalHour = 14
|
||||
val defaultTaskOverdueLocalHour = 9
|
||||
val defaultDailyDigestLocalHour = 8
|
||||
|
||||
// Track screen view and load preferences on first render
|
||||
LaunchedEffect(Unit) {
|
||||
// Attach per-category controller (Android only) and load initial state.
|
||||
LaunchedEffect(categoriesController) {
|
||||
PostHogAnalytics.screen(AnalyticsEvents.NOTIFICATION_SETTINGS_SCREEN_SHOWN)
|
||||
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) {
|
||||
if (preferencesState is ApiResult.Success) {
|
||||
val prefs = (preferencesState as ApiResult.Success).data
|
||||
@@ -75,38 +110,41 @@ fun NotificationPreferencesScreen(
|
||||
dailyDigest = prefs.dailyDigest
|
||||
emailTaskCompleted = prefs.emailTaskCompleted
|
||||
|
||||
// Load custom notification times (convert from UTC to local)
|
||||
prefs.taskDueSoonHour?.let { utcHour ->
|
||||
taskDueSoonHour = DateUtils.utcHourToLocal(utcHour)
|
||||
}
|
||||
prefs.taskOverdueHour?.let { utcHour ->
|
||||
taskOverdueHour = DateUtils.utcHourToLocal(utcHour)
|
||||
}
|
||||
prefs.warrantyExpiringHour?.let { utcHour ->
|
||||
warrantyExpiringHour = DateUtils.utcHourToLocal(utcHour)
|
||||
}
|
||||
prefs.dailyDigestHour?.let { utcHour ->
|
||||
dailyDigestHour = DateUtils.utcHourToLocal(utcHour)
|
||||
}
|
||||
prefs.taskDueSoonHour?.let { taskDueSoonHour = DateUtils.utcHourToLocal(it) }
|
||||
prefs.taskOverdueHour?.let { taskOverdueHour = DateUtils.utcHourToLocal(it) }
|
||||
prefs.warrantyExpiringHour?.let { warrantyExpiringHour = DateUtils.utcHourToLocal(it) }
|
||||
prefs.dailyDigestHour?.let { dailyDigestHour = DateUtils.utcHourToLocal(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val masterEnabled = remember(categoryState) {
|
||||
NotificationCategoriesController.computeMasterState(categoryState)
|
||||
}
|
||||
|
||||
WarmGradientBackground {
|
||||
Scaffold(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent,
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.notifications_title), fontWeight = FontWeight.SemiBold) },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(Res.string.notifications_title),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
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(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Transparent
|
||||
)
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -114,45 +152,126 @@ fun NotificationPreferencesScreen(
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
) {
|
||||
// Header
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
) {
|
||||
OrganicIconContainer(
|
||||
icon = Icons.Default.Notifications,
|
||||
size = 60.dp
|
||||
size = 60.dp,
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.notifications_preferences),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.notifications_choose),
|
||||
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) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.xl),
|
||||
contentAlignment = Alignment.Center
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
@@ -161,53 +280,50 @@ fun NotificationPreferencesScreen(
|
||||
is ApiResult.Error -> {
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
accentColor = MaterialTheme.colorScheme.errorContainer
|
||||
accentColor = MaterialTheme.colorScheme.errorContainer,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
(preferencesState as ApiResult.Error).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
OrganicPrimaryButton(
|
||||
text = stringResource(Res.string.common_retry),
|
||||
onClick = { viewModel.loadPreferences() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ApiResult.Success, is ApiResult.Idle -> {
|
||||
// Task Notifications Section
|
||||
Text(
|
||||
stringResource(Res.string.notifications_task_section),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = OrganicSpacing.md)
|
||||
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||
)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_task_due_soon),
|
||||
description = stringResource(Res.string.notifications_task_due_soon_desc),
|
||||
icon = Icons.Default.Schedule,
|
||||
@@ -216,10 +332,8 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
taskDueSoon = it
|
||||
viewModel.updatePreference(taskDueSoon = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Time picker for Task Due Soon
|
||||
if (taskDueSoon) {
|
||||
NotificationTimePickerRow(
|
||||
currentHour = taskDueSoonHour,
|
||||
@@ -229,15 +343,12 @@ fun NotificationPreferencesScreen(
|
||||
val utcHour = DateUtils.localHourToUtc(localHour)
|
||||
viewModel.updatePreference(taskDueSoonHour = utcHour)
|
||||
},
|
||||
onChangeTime = { showTaskDueSoonTimePicker = true }
|
||||
onChangeTime = { showTaskDueSoonTimePicker = true },
|
||||
)
|
||||
}
|
||||
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||
|
||||
OrganicDivider(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
||||
)
|
||||
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_task_overdue),
|
||||
description = stringResource(Res.string.notifications_task_overdue_desc),
|
||||
icon = Icons.Default.Warning,
|
||||
@@ -246,10 +357,8 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
taskOverdue = it
|
||||
viewModel.updatePreference(taskOverdue = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Time picker for Task Overdue
|
||||
if (taskOverdue) {
|
||||
NotificationTimePickerRow(
|
||||
currentHour = taskOverdueHour,
|
||||
@@ -259,15 +368,12 @@ fun NotificationPreferencesScreen(
|
||||
val utcHour = DateUtils.localHourToUtc(localHour)
|
||||
viewModel.updatePreference(taskOverdueHour = utcHour)
|
||||
},
|
||||
onChangeTime = { showTaskOverdueTimePicker = true }
|
||||
onChangeTime = { showTaskOverdueTimePicker = true },
|
||||
)
|
||||
}
|
||||
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||
|
||||
OrganicDivider(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
||||
)
|
||||
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_task_completed),
|
||||
description = stringResource(Res.string.notifications_task_completed_desc),
|
||||
icon = Icons.Default.CheckCircle,
|
||||
@@ -276,14 +382,11 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
taskCompleted = it
|
||||
viewModel.updatePreference(taskCompleted = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||
|
||||
OrganicDivider(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
||||
)
|
||||
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_task_assigned),
|
||||
description = stringResource(Res.string.notifications_task_assigned_desc),
|
||||
icon = Icons.Default.PersonAdd,
|
||||
@@ -292,12 +395,11 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
taskAssigned = it
|
||||
viewModel.updatePreference(taskAssigned = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Time picker dialogs
|
||||
if (showTaskDueSoonTimePicker) {
|
||||
HourPickerDialog(
|
||||
currentHour = taskDueSoonHour ?: defaultTaskDueSoonLocalHour,
|
||||
@@ -307,10 +409,9 @@ fun NotificationPreferencesScreen(
|
||||
viewModel.updatePreference(taskDueSoonHour = utcHour)
|
||||
showTaskDueSoonTimePicker = false
|
||||
},
|
||||
onDismiss = { showTaskDueSoonTimePicker = false }
|
||||
onDismiss = { showTaskDueSoonTimePicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showTaskOverdueTimePicker) {
|
||||
HourPickerDialog(
|
||||
currentHour = taskOverdueHour ?: defaultTaskOverdueLocalHour,
|
||||
@@ -320,23 +421,20 @@ fun NotificationPreferencesScreen(
|
||||
viewModel.updatePreference(taskOverdueHour = utcHour)
|
||||
showTaskOverdueTimePicker = false
|
||||
},
|
||||
onDismiss = { showTaskOverdueTimePicker = false }
|
||||
onDismiss = { showTaskOverdueTimePicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Other Notifications Section
|
||||
Text(
|
||||
stringResource(Res.string.notifications_other_section),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = OrganicSpacing.md)
|
||||
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||
)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_property_shared),
|
||||
description = stringResource(Res.string.notifications_property_shared_desc),
|
||||
icon = Icons.Default.Home,
|
||||
@@ -345,14 +443,11 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
residenceShared = it
|
||||
viewModel.updatePreference(residenceShared = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||
|
||||
OrganicDivider(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
||||
)
|
||||
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_warranty_expiring),
|
||||
description = stringResource(Res.string.notifications_warranty_expiring_desc),
|
||||
icon = Icons.Default.Description,
|
||||
@@ -361,14 +456,11 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
warrantyExpiring = it
|
||||
viewModel.updatePreference(warrantyExpiring = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.lg))
|
||||
|
||||
OrganicDivider(
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
|
||||
)
|
||||
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_daily_digest),
|
||||
description = stringResource(Res.string.notifications_daily_digest_desc),
|
||||
icon = Icons.Default.Summarize,
|
||||
@@ -377,10 +469,8 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
dailyDigest = it
|
||||
viewModel.updatePreference(dailyDigest = it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Time picker for Daily Digest
|
||||
if (dailyDigest) {
|
||||
NotificationTimePickerRow(
|
||||
currentHour = dailyDigestHour,
|
||||
@@ -390,13 +480,12 @@ fun NotificationPreferencesScreen(
|
||||
val utcHour = DateUtils.localHourToUtc(localHour)
|
||||
viewModel.updatePreference(dailyDigestHour = utcHour)
|
||||
},
|
||||
onChangeTime = { showDailyDigestTimePicker = true }
|
||||
onChangeTime = { showDailyDigestTimePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Daily Digest time picker dialog
|
||||
if (showDailyDigestTimePicker) {
|
||||
HourPickerDialog(
|
||||
currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour,
|
||||
@@ -406,23 +495,20 @@ fun NotificationPreferencesScreen(
|
||||
viewModel.updatePreference(dailyDigestHour = utcHour)
|
||||
showDailyDigestTimePicker = false
|
||||
},
|
||||
onDismiss = { showDailyDigestTimePicker = false }
|
||||
onDismiss = { showDailyDigestTimePicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Email Notifications Section
|
||||
Text(
|
||||
stringResource(Res.string.notifications_email_section),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = OrganicSpacing.md)
|
||||
modifier = Modifier.padding(top = OrganicSpacing.md),
|
||||
)
|
||||
|
||||
OrganicCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OrganicCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
NotificationToggle(
|
||||
NotificationToggleRow(
|
||||
title = stringResource(Res.string.notifications_email_task_completed),
|
||||
description = stringResource(Res.string.notifications_email_task_completed_desc),
|
||||
icon = Icons.Default.Email,
|
||||
@@ -431,7 +517,7 @@ fun NotificationPreferencesScreen(
|
||||
onCheckedChange = {
|
||||
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
|
||||
private fun NotificationToggle(
|
||||
private fun NotificationToggleRow(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
iconTint: androidx.compose.ui.graphics.Color,
|
||||
icon: ImageVector,
|
||||
iconTint: Color,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(OrganicSpacing.lg),
|
||||
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint
|
||||
tint = iconTint,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -497,41 +624,44 @@ private fun NotificationToggle(
|
||||
private fun NotificationTimePickerRow(
|
||||
currentHour: Int?,
|
||||
onSetCustomTime: () -> Unit,
|
||||
onChangeTime: () -> Unit
|
||||
onChangeTime: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.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),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccessTime,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (currentHour != null) {
|
||||
Text(
|
||||
text = DateUtils.formatHour(currentHour),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.notifications_change_time),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onChangeTime() }
|
||||
modifier = Modifier.clickable { onChangeTime() },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.notifications_set_custom_time),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onSetCustomTime() }
|
||||
modifier = Modifier.clickable { onSetCustomTime() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -542,7 +672,7 @@ private fun NotificationTimePickerRow(
|
||||
private fun HourPickerDialog(
|
||||
currentHour: Int,
|
||||
onHourSelected: (Int) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var selectedHour by remember { mutableStateOf(currentHour) }
|
||||
|
||||
@@ -551,82 +681,33 @@ private fun HourPickerDialog(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(Res.string.notifications_select_time),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
|
||||
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
|
||||
) {
|
||||
Text(
|
||||
text = DateUtils.formatHour(selectedHour),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
// Hour selector with AM/PM periods
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
// AM hours (6 AM - 11 AM)
|
||||
Column(
|
||||
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 = "AM", range = 6..11, selectedHour = selectedHour) {
|
||||
selectedHour = it
|
||||
}
|
||||
|
||||
// PM hours (12 PM - 5 PM)
|
||||
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 }
|
||||
)
|
||||
}
|
||||
HourColumn(label = "PM", range = 12..17, selectedHour = selectedHour) {
|
||||
selectedHour = it
|
||||
}
|
||||
|
||||
// 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 }
|
||||
)
|
||||
}
|
||||
HourColumn(label = "EVE", range = 18..23, selectedHour = selectedHour) {
|
||||
selectedHour = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,15 +721,41 @@ private fun HourPickerDialog(
|
||||
TextButton(onClick = onDismiss) {
|
||||
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
|
||||
private fun HourChip(
|
||||
hour: Int,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val displayHour = when {
|
||||
hour == 0 -> "12"
|
||||
@@ -662,15 +769,18 @@ private fun HourChip(
|
||||
.width(56.dp)
|
||||
.clickable { onClick() },
|
||||
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 = "$displayHour $amPm",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = OrganicSpacing.xs),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
modifier = Modifier.padding(
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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() {
|
||||
|
||||
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)
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
@@ -42,7 +115,7 @@ class NotificationPreferencesViewModel : ViewModel() {
|
||||
taskDueSoonHour: Int? = null,
|
||||
taskOverdueHour: Int? = null,
|
||||
warrantyExpiringHour: Int? = null,
|
||||
dailyDigestHour: Int? = null
|
||||
dailyDigestHour: Int? = null,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_updateState.value = ApiResult.Loading
|
||||
@@ -58,12 +131,11 @@ class NotificationPreferencesViewModel : ViewModel() {
|
||||
taskDueSoonHour = taskDueSoonHour,
|
||||
taskOverdueHour = taskOverdueHour,
|
||||
warrantyExpiringHour = warrantyExpiringHour,
|
||||
dailyDigestHour = dailyDigestHour
|
||||
dailyDigestHour = dailyDigestHour,
|
||||
)
|
||||
val result = APILayer.updateNotificationPreferences(request)
|
||||
_updateState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
// Update the preferences state with the new values
|
||||
_preferencesState.value = ApiResult.Success(result.data)
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
@@ -76,4 +148,41 @@ class NotificationPreferencesViewModel : ViewModel() {
|
||||
fun resetUpdateState() {
|
||||
_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