diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 8aafcbc..d8b834a 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -405,6 +405,11 @@
Task Completed Email
Receive email when a task is completed
+
+ Set custom time
+ Change
+ Select Notification Time
+
Save
Cancel
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt
index cbc1937..3d1d842 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt
index 40ea484..689146e 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt
@@ -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(null) }
+ var taskOverdueHour by remember { mutableStateOf(null) }
+ var warrantyExpiringHour by remember { mutableStateOf(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
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt
index cdc7210..169a8c2 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceFormScreen.kt
@@ -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>(emptyList()) }
+ var isLoadingUsers by remember { mutableStateOf(false) }
+ var userToRemove by remember { mutableStateOf(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
+ )
+ }
+ }
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt b/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt
index a8224d9..75fe58e 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt
@@ -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"
+ }
+ }
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt
index 11b005a..8c99e88 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt
@@ -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) {
diff --git a/iosApp/iosApp/Helpers/DateUtils.swift b/iosApp/iosApp/Helpers/DateUtils.swift
index 0970c89..6e712c8 100644
--- a/iosApp/iosApp/Helpers/DateUtils.swift
+++ b/iosApp/iosApp/Helpers/DateUtils.swift
@@ -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"
+ }
+ }
}
diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings
index 3cf76aa..65d45cd 100644
--- a/iosApp/iosApp/Localizable.xcstrings
+++ b/iosApp/iosApp/Localizable.xcstrings
@@ -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
diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift
index 6f87c87..7b05755 100644
--- a/iosApp/iosApp/Profile/NotificationPreferencesView.swift
+++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift
@@ -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 {
diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift
index db6dbdd..8fca418 100644
--- a/iosApp/iosApp/ResidenceFormView.swift
+++ b/iosApp/iosApp/ResidenceFormView.swift
@@ -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,
+ 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 {
+ 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") {