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

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