P4 Stream P: NotificationPreferencesScreen expansion

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

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

View File

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

View File

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

View File

@@ -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,
)
}
}
}

View File

@@ -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>

View File

@@ -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)?

View File

@@ -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,
)
}
}

View File

@@ -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
}

View File

@@ -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()))
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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