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

@@ -405,6 +405,11 @@
<string name="notifications_email_task_completed">Task Completed Email</string>
<string name="notifications_email_task_completed_desc">Receive email when a task is completed</string>
<!-- Notification Time Picker -->
<string name="notifications_set_custom_time">Set custom time</string>
<string name="notifications_change_time">Change</string>
<string name="notifications_select_time">Select Notification Time</string>
<!-- Common -->
<string name="common_save">Save</string>
<string name="common_cancel">Cancel</string>

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) {

View File

@@ -176,4 +176,73 @@ enum DateUtils {
let today = Calendar.current.startOfDay(for: Date())
return date < today
}
// MARK: - Timezone Conversion Utilities
/// Convert a local hour (0-23) to UTC hour
/// - Parameter localHour: Hour in the device's local timezone (0-23)
/// - Returns: Hour in UTC (0-23)
static func localHourToUtc(_ localHour: Int) -> Int {
let now = Date()
let calendar = Calendar.current
// Create a date with the given local hour
var components = calendar.dateComponents(in: TimeZone.current, from: now)
components.hour = localHour
components.minute = 0
components.second = 0
guard let localDate = calendar.date(from: components) else {
return localHour
}
// Get the hour in UTC
var utcCalendar = Calendar.current
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
let utcHour = utcCalendar.component(.hour, from: localDate)
return utcHour
}
/// Convert a UTC hour (0-23) to local hour
/// - Parameter utcHour: Hour in UTC (0-23)
/// - Returns: Hour in the device's local timezone (0-23)
static func utcHourToLocal(_ utcHour: Int) -> Int {
let now = Date()
// Create a calendar in UTC
var utcCalendar = Calendar.current
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
// Create a date with the given UTC hour
var components = utcCalendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: now)
components.hour = utcHour
components.minute = 0
components.second = 0
guard let utcDate = utcCalendar.date(from: components) else {
return utcHour
}
// Get the hour in local timezone
let localHour = Calendar.current.component(.hour, from: utcDate)
return localHour
}
/// Format an hour (0-23) to a human-readable 12-hour format
/// - Parameter hour: Hour in 24-hour format (0-23)
/// - Returns: Formatted string like "8:00 AM" or "2:00 PM"
static func formatHour(_ hour: Int) -> String {
switch hour {
case 0:
return "12:00 AM"
case 1..<12:
return "\(hour):00 AM"
case 12:
return "12:00 PM"
default:
return "\(hour - 12):00 PM"
}
}
}

View File

@@ -332,6 +332,9 @@
"Are you sure you want to cancel this task? This action cannot be undone." : {
"comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in the task details view. It confirms that the user intends to cancel the task and warns them that the action cannot be undone.",
"isCommentAutoGenerated" : true
},
"Are you sure you want to remove %@ from this residence?" : {
},
"At least 8 characters" : {
@@ -4270,6 +4273,10 @@
},
"CASERA PRO" : {
},
"Change" : {
"comment" : "A button that allows the user to change the time in a notification.",
"isCommentAutoGenerated" : true
},
"Check Your Email" : {
"comment" : "A heading that instructs the user to check their email for a verification code.",
@@ -17324,6 +17331,10 @@
"comment" : "A button label that says \"Generate\".",
"isCommentAutoGenerated" : true
},
"Hour" : {
"comment" : "A picker for selecting an hour.",
"isCommentAutoGenerated" : true
},
"I have a code to join" : {
"comment" : "A button label that instructs the user to join an existing Casera account.",
"isCommentAutoGenerated" : true
@@ -17415,6 +17426,9 @@
},
"No properties yet" : {
},
"No shared users" : {
},
"No tasks yet" : {
"comment" : "A description displayed when a user has no tasks.",
@@ -17428,6 +17442,18 @@
"comment" : "A message displayed when no task templates match a search query.",
"isCommentAutoGenerated" : true
},
"Notification Time" : {
"comment" : "The title of the sheet where a user can select the time for receiving notifications.",
"isCommentAutoGenerated" : true
},
"Notifications will be sent at %@ in your local timezone" : {
"comment" : "A label below the time picker, explaining that the notifications will be sent at the selected time in the user's local timezone.",
"isCommentAutoGenerated" : true
},
"Notify at %@" : {
"comment" : "A row in the checkout view that lets the user change the time they want to be notified.",
"isCommentAutoGenerated" : true
},
"OK" : {
"comment" : "A button that dismisses the success dialog.",
"isCommentAutoGenerated" : true
@@ -21302,6 +21328,12 @@
},
"Re-enter new password" : {
},
"Remove" : {
},
"Remove User" : {
},
"Reset Password" : {
"comment" : "The title of the screen where users can reset their passwords.",
@@ -24456,6 +24488,10 @@
},
"Return to Login" : {
},
"Save" : {
"comment" : "The text for a button that saves the selected time.",
"isCommentAutoGenerated" : true
},
"Save your home to your account" : {
@@ -24464,6 +24500,10 @@
"comment" : "A placeholder text for a search bar in the task templates browser.",
"isCommentAutoGenerated" : true
},
"Select Notification Time" : {
"comment" : "A label displayed above the picker for selecting the notification time.",
"isCommentAutoGenerated" : true
},
"Send New Code" : {
"comment" : "A button label that allows a user to request a new verification code.",
"isCommentAutoGenerated" : true
@@ -24472,6 +24512,10 @@
"comment" : "A button label that says \"Send Reset Code\".",
"isCommentAutoGenerated" : true
},
"Set Custom Time" : {
"comment" : "A button that allows a user to set a custom notification time.",
"isCommentAutoGenerated" : true
},
"Set New Password" : {
},
@@ -24616,6 +24660,9 @@
"Share this code with others to give them access to %@" : {
"comment" : "A caption below the share code, explaining that it can be shared with others to give them access to a residence. The argument is the name of the residence.",
"isCommentAutoGenerated" : true
},
"Shared Users (%lld)" : {
},
"Signing in with Apple..." : {
@@ -29650,6 +29697,13 @@
"comment" : "A description of the benefit of upgrading to the Pro plan.",
"isCommentAutoGenerated" : true
},
"Users with access to this residence. Use the share button to invite others." : {
},
"Using system default time" : {
"comment" : "A description of how a user can set a custom notification time.",
"isCommentAutoGenerated" : true
},
"Verification Code" : {
"comment" : "A label displayed above the text field for entering a verification code.",
"isCommentAutoGenerated" : true

View File

@@ -79,6 +79,21 @@ struct NotificationPreferencesView: View {
viewModel.updatePreference(taskDueSoon: newValue)
}
// Time picker for Task Due Soon
if viewModel.taskDueSoon {
NotificationTimePickerRow(
isEnabled: $viewModel.taskDueSoonTimeEnabled,
selectedHour: $viewModel.taskDueSoonHour,
onEnableCustomTime: {
viewModel.enableCustomTime(for: .taskDueSoon)
},
onTimeChange: { hour in
viewModel.updateCustomTime(hour, for: .taskDueSoon)
},
formatHour: viewModel.formatHour
)
}
Toggle(isOn: $viewModel.taskOverdue) {
Label {
VStack(alignment: .leading, spacing: 2) {
@@ -98,6 +113,21 @@ struct NotificationPreferencesView: View {
viewModel.updatePreference(taskOverdue: newValue)
}
// Time picker for Task Overdue
if viewModel.taskOverdue {
NotificationTimePickerRow(
isEnabled: $viewModel.taskOverdueTimeEnabled,
selectedHour: $viewModel.taskOverdueHour,
onEnableCustomTime: {
viewModel.enableCustomTime(for: .taskOverdue)
},
onTimeChange: { hour in
viewModel.updateCustomTime(hour, for: .taskOverdue)
},
formatHour: viewModel.formatHour
)
}
Toggle(isOn: $viewModel.taskCompleted) {
Label {
VStack(alignment: .leading, spacing: 2) {
@@ -250,10 +280,25 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
@Published var warrantyExpiring: Bool = true
@Published var emailTaskCompleted: Bool = true
// Custom notification times (local hours, 0-23)
@Published var taskDueSoonHour: Int? = nil
@Published var taskOverdueHour: Int? = nil
@Published var warrantyExpiringHour: Int? = nil
// Track if user has enabled custom times
@Published var taskDueSoonTimeEnabled: Bool = false
@Published var taskOverdueTimeEnabled: Bool = false
@Published var warrantyExpiringTimeEnabled: Bool = false
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isSaving: Bool = false
// Default local hours when user first enables custom time
private let defaultTaskDueSoonLocalHour = 14 // 2 PM local
private let defaultTaskOverdueLocalHour = 9 // 9 AM local
private let defaultWarrantyExpiringLocalHour = 10 // 10 AM local
func loadPreferences() {
isLoading = true
errorMessage = nil
@@ -270,6 +315,21 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
self.residenceShared = prefs.residenceShared
self.warrantyExpiring = prefs.warrantyExpiring
self.emailTaskCompleted = prefs.emailTaskCompleted
// Load custom notification times (convert from UTC to local)
if let utcHour = prefs.taskDueSoonHour?.intValue {
self.taskDueSoonHour = DateUtils.utcHourToLocal(Int(utcHour))
self.taskDueSoonTimeEnabled = true
}
if let utcHour = prefs.taskOverdueHour?.intValue {
self.taskOverdueHour = DateUtils.utcHourToLocal(Int(utcHour))
self.taskOverdueTimeEnabled = true
}
if let utcHour = prefs.warrantyExpiringHour?.intValue {
self.warrantyExpiringHour = DateUtils.utcHourToLocal(Int(utcHour))
self.warrantyExpiringTimeEnabled = true
}
self.isLoading = false
self.errorMessage = nil
} else if let error = result as? ApiResultError {
@@ -290,12 +350,20 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
taskAssigned: Bool? = nil,
residenceShared: Bool? = nil,
warrantyExpiring: Bool? = nil,
emailTaskCompleted: Bool? = nil
emailTaskCompleted: Bool? = nil,
taskDueSoonHour: Int? = nil,
taskOverdueHour: Int? = nil,
warrantyExpiringHour: Int? = nil
) {
isSaving = true
Task {
do {
// Convert local hours to UTC before sending
let taskDueSoonUtc = taskDueSoonHour.map { DateUtils.localHourToUtc($0) }
let taskOverdueUtc = taskOverdueHour.map { DateUtils.localHourToUtc($0) }
let warrantyExpiringUtc = warrantyExpiringHour.map { DateUtils.localHourToUtc($0) }
let request = UpdateNotificationPreferencesRequest(
taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) },
taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) },
@@ -303,7 +371,10 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) },
residenceShared: residenceShared.map { KotlinBoolean(bool: $0) },
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) },
emailTaskCompleted: emailTaskCompleted.map { KotlinBoolean(bool: $0) }
emailTaskCompleted: emailTaskCompleted.map { KotlinBoolean(bool: $0) },
taskDueSoonHour: taskDueSoonUtc.map { KotlinInt(int: Int32($0)) },
taskOverdueHour: taskOverdueUtc.map { KotlinInt(int: Int32($0)) },
warrantyExpiringHour: warrantyExpiringUtc.map { KotlinInt(int: Int32($0)) }
)
let result = try await APILayer.shared.updateNotificationPreferences(request: request)
@@ -319,6 +390,172 @@ class NotificationPreferencesViewModelWrapper: ObservableObject {
}
}
}
// Helper to enable custom time with default value
func enableCustomTime(for type: NotificationType) {
switch type {
case .taskDueSoon:
taskDueSoonHour = defaultTaskDueSoonLocalHour
taskDueSoonTimeEnabled = true
updatePreference(taskDueSoonHour: defaultTaskDueSoonLocalHour)
case .taskOverdue:
taskOverdueHour = defaultTaskOverdueLocalHour
taskOverdueTimeEnabled = true
updatePreference(taskOverdueHour: defaultTaskOverdueLocalHour)
case .warrantyExpiring:
warrantyExpiringHour = defaultWarrantyExpiringLocalHour
warrantyExpiringTimeEnabled = true
updatePreference(warrantyExpiringHour: defaultWarrantyExpiringLocalHour)
}
}
// Helper to update custom time
func updateCustomTime(_ hour: Int, for type: NotificationType) {
switch type {
case .taskDueSoon:
taskDueSoonHour = hour
updatePreference(taskDueSoonHour: hour)
case .taskOverdue:
taskOverdueHour = hour
updatePreference(taskOverdueHour: hour)
case .warrantyExpiring:
warrantyExpiringHour = hour
updatePreference(warrantyExpiringHour: hour)
}
}
enum NotificationType {
case taskDueSoon
case taskOverdue
case warrantyExpiring
}
// Format hour to display string
func formatHour(_ hour: Int) -> String {
switch hour {
case 0: return "12:00 AM"
case 1...11: return "\(hour):00 AM"
case 12: return "12:00 PM"
default: return "\(hour - 12):00 PM"
}
}
}
// MARK: - NotificationTimePickerRow
struct NotificationTimePickerRow: View {
@Binding var isEnabled: Bool
@Binding var selectedHour: Int?
let onEnableCustomTime: () -> Void
let onTimeChange: (Int) -> Void
let formatHour: (Int) -> String
@State private var showingTimePicker = false
var body: some View {
HStack {
Image(systemName: "clock")
.foregroundColor(Color.appTextSecondary)
.font(.caption)
if isEnabled, let hour = selectedHour {
Text("Notify at \(formatHour(hour))")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Spacer()
Button("Change") {
showingTimePicker = true
}
.font(.subheadline)
.foregroundColor(Color.appPrimary)
} else {
Text("Using system default time")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Spacer()
Button("Set Custom Time") {
onEnableCustomTime()
}
.font(.subheadline)
.foregroundColor(Color.appPrimary)
}
}
.padding(.leading, 28) // Indent to align with toggle content
.sheet(isPresented: $showingTimePicker) {
TimePickerSheet(
selectedHour: selectedHour ?? 9,
onSave: { hour in
onTimeChange(hour)
showingTimePicker = false
},
onCancel: {
showingTimePicker = false
},
formatHour: formatHour
)
}
}
}
// MARK: - TimePickerSheet
struct TimePickerSheet: View {
@State var selectedHour: Int
let onSave: (Int) -> Void
let onCancel: () -> Void
let formatHour: (Int) -> String
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Text("Select Notification Time")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
.padding(.top)
Picker("Hour", selection: $selectedHour) {
ForEach(0..<24, id: \.self) { hour in
Text(formatHour(hour))
.tag(hour)
}
}
.pickerStyle(.wheel)
.frame(height: 150)
Text("Notifications will be sent at \(formatHour(selectedHour)) in your local timezone")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Spacer()
}
.padding()
.background(Color.appBackgroundPrimary)
.navigationTitle("Notification Time")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
onCancel()
}
.foregroundColor(Color.appTextSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(selectedHour)
}
.foregroundColor(Color.appPrimary)
.fontWeight(.semibold)
}
}
}
.presentationDetents([.medium])
}
}
#Preview {

View File

@@ -12,6 +12,19 @@ struct ResidenceFormView: View {
// Lookups from DataManagerObservable
private var residenceTypes: [ResidenceType] { dataManager.residenceTypes }
// User management state
@State private var users: [ResidenceUserResponse] = []
@State private var isLoadingUsers = false
@State private var userToRemove: ResidenceUserResponse?
@State private var showRemoveUserConfirmation = false
// Check if current user is the owner
private var isCurrentUserOwner: Bool {
guard let residence = existingResidence,
let currentUser = dataManager.currentUser else { return false }
return Int(residence.ownerId) == Int(currentUser.id)
}
// Form fields
@State private var name: String = ""
@State private var selectedPropertyType: ResidenceType?
@@ -154,6 +167,38 @@ struct ResidenceFormView: View {
}
.listRowBackground(Color.appBackgroundSecondary)
// Users section (edit mode only, owner only)
if isEditMode && isCurrentUserOwner {
Section {
if isLoadingUsers {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if users.isEmpty {
Text("No shared users")
.foregroundColor(.secondary)
} else {
ForEach(users, id: \.id) { user in
UserRow(
user: user,
isOwner: user.id == existingResidence?.ownerId,
onRemove: {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
} header: {
Text("Shared Users (\(users.count))")
} footer: {
Text("Users with access to this residence. Use the share button to invite others.")
}
.listRowBackground(Color.appBackgroundSecondary)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
@@ -187,6 +232,23 @@ struct ResidenceFormView: View {
.onAppear {
loadResidenceTypes()
initializeForm()
if isEditMode && isCurrentUserOwner {
loadUsers()
}
}
.alert("Remove User", isPresented: $showRemoveUserConfirmation) {
Button("Cancel", role: .cancel) {
userToRemove = nil
}
Button("Remove", role: .destructive) {
if let user = userToRemove {
removeUser(user)
}
}
} message: {
if let user = userToRemove {
Text("Are you sure you want to remove \(user.username) from this residence?")
}
}
.handleErrors(
error: viewModel.errorMessage,
@@ -312,6 +374,107 @@ struct ResidenceFormView: View {
}
}
}
private func loadUsers() {
guard let residence = existingResidence,
TokenStorage.shared.getToken() != nil else { return }
isLoadingUsers = true
Task {
do {
let result = try await APILayer.shared.getResidenceUsers(residenceId: residence.id)
await MainActor.run {
if let successResult = result as? ApiResultSuccess<NSArray>,
let responseData = successResult.data as? [ResidenceUserResponse] {
// Filter out the owner from the list
self.users = responseData.filter { $0.id != residence.ownerId }
}
self.isLoadingUsers = false
}
} catch {
await MainActor.run {
self.isLoadingUsers = false
}
}
}
}
private func removeUser(_ user: ResidenceUserResponse) {
guard let residence = existingResidence,
TokenStorage.shared.getToken() != nil else { return }
Task {
do {
let result = try await APILayer.shared.removeUser(residenceId: residence.id, userId: user.id)
await MainActor.run {
if result is ApiResultSuccess<RemoveUserResponse> {
self.users.removeAll { $0.id == user.id }
}
self.userToRemove = nil
}
} catch {
await MainActor.run {
self.userToRemove = nil
}
}
}
}
}
// MARK: - User Row Component
private struct UserRow: View {
let user: ResidenceUserResponse
let isOwner: Bool
let onRemove: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(user.username)
.font(.body)
if isOwner {
Text("Owner")
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.appPrimary)
.clipShape(Capsule())
}
}
if !user.email.isEmpty {
Text(user.email)
.font(.caption)
.foregroundColor(.secondary)
}
let fullName = [user.firstName, user.lastName]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " ")
if !fullName.isEmpty {
Text(fullName)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
if !isOwner {
Button(action: onRemove) {
Image(systemName: "trash")
.foregroundColor(Color.appError)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
#Preview("Add Mode") {