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

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