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

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