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

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