diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPreferencesStore.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPreferencesStore.kt new file mode 100644 index 0000000..ed2ac82 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPreferencesStore.kt @@ -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 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 = 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> = 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) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.android.kt new file mode 100644 index 0000000..2b00aea --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.android.kt @@ -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) + } + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPreferencesStoreTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPreferencesStoreTest.kt new file mode 100644 index 0000000..54cba2a --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPreferencesStoreTest.kt @@ -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>() + 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, + ) + } + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index b5ea111..e43a1f1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -626,6 +626,21 @@ Change Select Notification Time + + All notifications + Turn every category on or off in one tap + Categories + Task reminders + Upcoming and due-soon reminders + Overdue tasks + Alerts when a task is past its due date + Residence invites + Invitations to join a shared residence + Subscription updates + Billing and plan status changes + Open system settings + Fine-tune sounds, badges, and Do Not Disturb behaviour in Android settings + Save Cancel diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.kt new file mode 100644 index 0000000..0546ca1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.kt @@ -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)? diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreen.kt index a3a3b3e..9045d37 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreen.kt @@ -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(null) } var taskOverdueHour by remember { mutableStateOf(null) } var warrantyExpiringHour by remember { mutableStateOf(null) } var dailyDigestHour by remember { mutableStateOf(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 = 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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/NotificationPreferencesViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/NotificationPreferencesViewModel.kt index 00dd4d1..901fbfd 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/NotificationPreferencesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/NotificationPreferencesViewModel.kt @@ -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 = 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, + private val setCategory: suspend (String, Boolean) -> Unit, + private val setAll: suspend (Boolean) -> Unit, +) { + suspend fun load(): Map = 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): 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.Idle) - val preferencesState: StateFlow> = _preferencesState + val preferencesState: StateFlow> = _preferencesState.asStateFlow() private val _updateState = MutableStateFlow>(ApiResult.Idle) - val updateState: StateFlow> = _updateState + val updateState: StateFlow> = _updateState.asStateFlow() + + /** + * Per-category local toggle state, keyed by [NotificationCategoryKeys] + * channel ids. Backed on Android by `NotificationPreferencesStore` via + * [NotificationCategoriesController]. + */ + private val _categoryState = MutableStateFlow>( + NotificationCategoryKeys.ALL.associateWith { true }, + ) + val categoryState: StateFlow> = _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 } diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreenTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreenTest.kt new file mode 100644 index 0000000..272325c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesScreenTest.kt @@ -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 = NotificationCategoryKeys.ALL.associateWith { true }, + ) { + val state = initial.toMutableMap() + val setCategoryCalls: MutableList> = mutableListOf() + val setAllCalls: MutableList = 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 = + 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())) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.ios.kt new file mode 100644 index 0000000..379eb25 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.ios.kt @@ -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 diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.js.kt new file mode 100644 index 0000000..d0deb02 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.js.kt @@ -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 diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.jvm.kt new file mode 100644 index 0000000..d0deb02 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.jvm.kt @@ -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 diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.wasmJs.kt new file mode 100644 index 0000000..d0deb02 --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/screens/NotificationPreferencesPlatform.wasmJs.kt @@ -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