Add per-user notification time preferences

Allow users to customize when they receive notification reminders:
- Add hour fields to NotificationPreference model
- Add timezone conversion utilities (localHourToUtc, utcHourToLocal)
- Add time picker UI for iOS (wheel picker in sheet)
- Add time picker UI for Android (hour chip selector dialog)
- Times stored in UTC, displayed in user's local timezone
- Add localized strings for time picker UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-07 00:25:38 -06:00
parent 83e2cd14a6
commit 9d6e7c4f2a
10 changed files with 1086 additions and 7 deletions

View File

@@ -43,7 +43,14 @@ data class NotificationPreference(
val warrantyExpiring: Boolean = true,
// Email preferences
@SerialName("email_task_completed")
val emailTaskCompleted: Boolean = true
val emailTaskCompleted: Boolean = true,
// Custom notification times (UTC hour 0-23, null means use system default)
@SerialName("task_due_soon_hour")
val taskDueSoonHour: Int? = null,
@SerialName("task_overdue_hour")
val taskOverdueHour: Int? = null,
@SerialName("warranty_expiring_hour")
val warrantyExpiringHour: Int? = null
)
@Serializable
@@ -62,7 +69,14 @@ data class UpdateNotificationPreferencesRequest(
val warrantyExpiring: Boolean? = null,
// Email preferences
@SerialName("email_task_completed")
val emailTaskCompleted: Boolean? = null
val emailTaskCompleted: Boolean? = null,
// Custom notification times (UTC hour 0-23)
@SerialName("task_due_soon_hour")
val taskDueSoonHour: Int? = null,
@SerialName("task_overdue_hour")
val taskOverdueHour: Int? = null,
@SerialName("warranty_expiring_hour")
val warrantyExpiringHour: Int? = null
)
@Serializable

View File

@@ -1,5 +1,6 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -16,6 +17,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.NotificationPreferencesViewModel
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -37,6 +39,19 @@ fun NotificationPreferencesScreen(
var warrantyExpiring 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) }
// Time picker dialog states
var showTaskDueSoonTimePicker by remember { mutableStateOf(false) }
var showTaskOverdueTimePicker 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
// Load preferences on first render
LaunchedEffect(Unit) {
viewModel.loadPreferences()
@@ -53,6 +68,17 @@ fun NotificationPreferencesScreen(
residenceShared = prefs.residenceShared
warrantyExpiring = prefs.warrantyExpiring
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)
}
}
}
@@ -196,6 +222,20 @@ fun NotificationPreferencesScreen(
}
)
// Time picker for Task Due Soon
if (taskDueSoon) {
NotificationTimePickerRow(
currentHour = taskDueSoonHour,
onSetCustomTime = {
val localHour = defaultTaskDueSoonLocalHour
taskDueSoonHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(taskDueSoonHour = utcHour)
},
onChangeTime = { showTaskDueSoonTimePicker = true }
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
@@ -213,6 +253,20 @@ fun NotificationPreferencesScreen(
}
)
// Time picker for Task Overdue
if (taskOverdue) {
NotificationTimePickerRow(
currentHour = taskOverdueHour,
onSetCustomTime = {
val localHour = defaultTaskOverdueLocalHour
taskOverdueHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(taskOverdueHour = utcHour)
},
onChangeTime = { showTaskOverdueTimePicker = true }
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
@@ -249,6 +303,33 @@ fun NotificationPreferencesScreen(
}
}
// Time picker dialogs
if (showTaskDueSoonTimePicker) {
HourPickerDialog(
currentHour = taskDueSoonHour ?: defaultTaskDueSoonLocalHour,
onHourSelected = { hour ->
taskDueSoonHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(taskDueSoonHour = utcHour)
showTaskDueSoonTimePicker = false
},
onDismiss = { showTaskDueSoonTimePicker = false }
)
}
if (showTaskOverdueTimePicker) {
HourPickerDialog(
currentHour = taskOverdueHour ?: defaultTaskOverdueLocalHour,
onHourSelected = { hour ->
taskOverdueHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(taskOverdueHour = utcHour)
showTaskOverdueTimePicker = false
},
onDismiss = { showTaskOverdueTimePicker = false }
)
}
// Other Notifications Section
Text(
stringResource(Res.string.notifications_other_section),
@@ -381,3 +462,185 @@ private fun NotificationToggle(
)
}
}
@Composable
private fun NotificationTimePickerRow(
currentHour: Int?,
onSetCustomTime: () -> Unit,
onChangeTime: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = AppSpacing.lg + 24.dp + AppSpacing.md, end = AppSpacing.lg, bottom = AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AccessTime,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
if (currentHour != null) {
Text(
text = DateUtils.formatHour(currentHour),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = stringResource(Res.string.notifications_change_time),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
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() }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HourPickerDialog(
currentHour: Int,
onHourSelected: (Int) -> Unit,
onDismiss: () -> Unit
) {
var selectedHour by remember { mutableStateOf(currentHour) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(Res.string.notifications_select_time),
fontWeight = FontWeight.SemiBold
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Text(
text = DateUtils.formatHour(selectedHour),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
// Hour selector with AM/PM periods
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// AM hours (6 AM - 11 AM)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
"AM",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold
)
(6..11).forEach { hour ->
HourChip(
hour = hour,
isSelected = selectedHour == hour,
onClick = { selectedHour = hour }
)
}
}
// PM hours (12 PM - 5 PM)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
"PM",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold
)
(12..17).forEach { hour ->
HourChip(
hour = hour,
isSelected = selectedHour == hour,
onClick = { selectedHour = hour }
)
}
}
// Evening hours (6 PM - 11 PM)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
"EVE",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold
)
(18..23).forEach { hour ->
HourChip(
hour = hour,
isSelected = selectedHour == hour,
onClick = { selectedHour = hour }
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { onHourSelected(selectedHour) }) {
Text(stringResource(Res.string.common_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(Res.string.common_cancel))
}
}
)
}
@Composable
private fun HourChip(
hour: Int,
isSelected: Boolean,
onClick: () -> Unit
) {
val displayHour = when {
hour == 0 -> "12"
hour > 12 -> "${hour - 12}"
else -> "$hour"
}
val amPm = if (hour < 12) "AM" else "PM"
Surface(
modifier = Modifier
.width(56.dp)
.clickable { onClick() },
shape = RoundedCornerShape(AppRadius.sm),
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 = AppSpacing.sm, vertical = AppSpacing.xs),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}

View File

@@ -6,19 +6,26 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository
import com.example.casera.data.DataManager
import com.example.casera.models.Residence
import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.ResidenceType
import com.example.casera.models.ResidenceUser
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import casera.composeapp.generated.resources.*
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@@ -55,6 +62,42 @@ fun ResidenceFormScreen(
viewModel.createResidenceState.collectAsState()
}
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
val currentUser by DataManager.currentUser.collectAsState()
// Check if current user is the owner
val isCurrentUserOwner = remember(existingResidence, currentUser) {
existingResidence != null && currentUser != null && existingResidence.ownerId == currentUser?.id
}
// User management state (edit mode only, owner only)
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
var isLoadingUsers by remember { mutableStateOf(false) }
var userToRemove by remember { mutableStateOf<ResidenceUser?>(null) }
var showRemoveUserConfirmation by remember { mutableStateOf(false) }
val residenceApi = remember { ResidenceApi() }
val scope = rememberCoroutineScope()
// Load users when in edit mode and user is owner
LaunchedEffect(isEditMode, isCurrentUserOwner, existingResidence?.id) {
if (isEditMode && isCurrentUserOwner && existingResidence != null) {
isLoadingUsers = true
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.getResidenceUsers(token, existingResidence.id)) {
is ApiResult.Success -> {
// Filter out the owner from the list
users = result.data.filter { it.id != existingResidence.ownerId }
}
is ApiResult.Error -> {
// Silently fail - users list will be empty
}
else -> {}
}
}
isLoadingUsers = false
}
}
// Validation errors
var nameError by remember { mutableStateOf("") }
@@ -295,6 +338,49 @@ fun ResidenceFormScreen(
)
}
// Users section (edit mode only, owner only)
if (isEditMode && isCurrentUserOwner) {
Divider()
Text(
text = "Shared Users (${users.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
if (isLoadingUsers) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
} else if (users.isEmpty()) {
Text(
text = "No shared users",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
users.forEach { user ->
UserListItem(
user = user,
onRemove = {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
Text(
text = "Users with access to this residence. Use the share button to invite others.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
// Error message
if (operationState is ApiResult.Error) {
Text(
@@ -349,4 +435,105 @@ fun ResidenceFormScreen(
Spacer(modifier = Modifier.height(16.dp))
}
}
// Remove user confirmation dialog
if (showRemoveUserConfirmation && userToRemove != null) {
AlertDialog(
onDismissRequest = {
showRemoveUserConfirmation = false
userToRemove = null
},
title = { Text("Remove User") },
text = {
Text("Are you sure you want to remove ${userToRemove?.username} from this residence?")
},
confirmButton = {
TextButton(
onClick = {
userToRemove?.let { user ->
scope.launch {
val token = TokenStorage.getToken()
if (token != null && existingResidence != null) {
when (residenceApi.removeUser(token, existingResidence.id, user.id)) {
is ApiResult.Success -> {
users = users.filter { it.id != user.id }
}
is ApiResult.Error -> {
// Silently fail
}
else -> {}
}
}
}
}
showRemoveUserConfirmation = false
userToRemove = null
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Remove")
}
},
dismissButton = {
TextButton(
onClick = {
showRemoveUserConfirmation = false
userToRemove = null
}
) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun UserListItem(
user: ResidenceUser,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.username,
style = MaterialTheme.typography.bodyLarge
)
if (!user.email.isNullOrEmpty()) {
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val fullName = listOfNotNull(user.firstName, user.lastName)
.filter { it.isNotEmpty() }
.joinToString(" ")
if (fullName.isNotEmpty()) {
Text(
text = fullName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove user",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}

View File

@@ -1,18 +1,22 @@
package com.example.casera.util
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
/**
* Utility object for formatting dates in a human-readable format
*/
@OptIn(kotlin.time.ExperimentalTime::class)
@OptIn(ExperimentalTime::class)
object DateUtils {
private fun getToday(): LocalDate {
@@ -163,4 +167,81 @@ object DateUtils {
false
}
}
/**
* Convert a local hour (0-23) to UTC hour
* @param localHour Hour in the device's local timezone (0-23)
* @return Hour in UTC (0-23)
*/
fun localHourToUtc(localHour: Int): Int {
val tz = TimeZone.currentSystemDefault()
val nowMillis = Clock.System.now().toEpochMilliseconds()
val now = Instant.fromEpochMilliseconds(nowMillis)
val today = now.toLocalDateTime(tz).date
// Create a LocalDateTime with the given local hour
val localDateTime = LocalDateTime(today, LocalTime(localHour, 0))
// Convert to UTC instant, then to UTC datetime
val instant = localDateTime.toInstant(tz)
val utcDateTime = instant.toLocalDateTime(TimeZone.UTC)
return utcDateTime.hour
}
/**
* Convert a UTC hour (0-23) to local hour
* @param utcHour Hour in UTC (0-23)
* @return Hour in the device's local timezone (0-23)
*/
fun utcHourToLocal(utcHour: Int): Int {
val tz = TimeZone.currentSystemDefault()
val nowMillis = Clock.System.now().toEpochMilliseconds()
val now = Instant.fromEpochMilliseconds(nowMillis)
val today = now.toLocalDateTime(TimeZone.UTC).date
// Create a LocalDateTime in UTC with the given UTC hour
val utcDateTime = LocalDateTime(today, LocalTime(utcHour, 0))
// Convert to local instant, then to local datetime
val instant = utcDateTime.toInstant(TimeZone.UTC)
val localDateTime = instant.toLocalDateTime(tz)
return localDateTime.hour
}
/**
* Get the current timezone offset in hours from UTC
* @return Offset in hours (e.g., -6 for CST)
*/
fun getTimezoneOffsetHours(): Int {
val tz = TimeZone.currentSystemDefault()
val nowMillis = Clock.System.now().toEpochMilliseconds()
val now = Instant.fromEpochMilliseconds(nowMillis)
// Get UTC hour and local hour for the same instant
val utcHour = now.toLocalDateTime(TimeZone.UTC).hour
val localHour = now.toLocalDateTime(tz).hour
// Calculate offset (handle day boundary)
var offset = localHour - utcHour
if (offset > 12) offset -= 24
if (offset < -12) offset += 24
return offset
}
/**
* Format an hour (0-23) to a human-readable 12-hour format
* @param hour Hour in 24-hour format (0-23)
* @return Formatted string like "8:00 AM" or "2:00 PM"
*/
fun formatHour(hour: Int): String {
return when {
hour == 0 -> "12:00 AM"
hour < 12 -> "$hour:00 AM"
hour == 12 -> "12:00 PM"
else -> "${hour - 12}:00 PM"
}
}
}

View File

@@ -37,7 +37,10 @@ class NotificationPreferencesViewModel : ViewModel() {
taskAssigned: Boolean? = null,
residenceShared: Boolean? = null,
warrantyExpiring: Boolean? = null,
emailTaskCompleted: Boolean? = null
emailTaskCompleted: Boolean? = null,
taskDueSoonHour: Int? = null,
taskOverdueHour: Int? = null,
warrantyExpiringHour: Int? = null
) {
viewModelScope.launch {
_updateState.value = ApiResult.Loading
@@ -48,7 +51,10 @@ class NotificationPreferencesViewModel : ViewModel() {
taskAssigned = taskAssigned,
residenceShared = residenceShared,
warrantyExpiring = warrantyExpiring,
emailTaskCompleted = emailTaskCompleted
emailTaskCompleted = emailTaskCompleted,
taskDueSoonHour = taskDueSoonHour,
taskOverdueHour = taskOverdueHour,
warrantyExpiringHour = warrantyExpiringHour
)
val result = APILayer.updateNotificationPreferences(request)
_updateState.value = when (result) {