Files
honeyDueKMP/iosApp/iosApp/Profile/NotificationPreferencesView.swift
Trey T af73f8861b iOS VoiceOver accessibility overhaul — 67 files
New framework:
- AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings
- AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative,
  .a11yButton, .a11yCard, .a11yStatValue View extensions

Shared components: decorative elements hidden, stat views combined,
status/priority badges labeled, error views announced, empty states grouped

Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard,
DocumentCard, WarrantyCard — all grouped with combined labels,
chevrons hidden, action buttons labeled

Main screens: Login, Register, Residences, Tasks, Contractors, Documents —
toolbar buttons labeled, section headers marked, form field hints added

Onboarding: all 10 views — header traits, button hints, task selection
state, progress indicator, decorative backgrounds hidden

Profile/Subscription: toggle hints, theme selection state, feature
comparison table accessibility, subscription button labels

iOS build verified: BUILD SUCCEEDED
2026-03-26 14:51:29 -05:00

722 lines
32 KiB
Swift

import SwiftUI
import ComposeApp
struct NotificationPreferencesView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = NotificationPreferencesViewModelWrapper()
@State private var isInitialLoad = true
var body: some View {
NavigationStack {
ZStack {
WarmGradientBackground()
.ignoresSafeArea()
Form {
// Header Section
Section {
VStack(spacing: OrganicSpacing.cozy) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 50
)
)
.frame(width: 100, height: 100)
Image(systemName: "bell.badge.fill")
.font(.system(size: 48))
.foregroundStyle(Color.appPrimary.gradient)
}
Text(L10n.Profile.notificationPreferences)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text(L10n.Profile.notificationPreferencesSubtitle)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
if viewModel.isLoading {
Section {
HStack {
Spacer()
ProgressView()
.tint(Color.appPrimary)
Spacer()
}
.padding(.vertical, 20)
}
.sectionBackground()
} else if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.foregroundColor(Color.appError)
.font(.subheadline)
}
Button(L10n.Common.retry) {
viewModel.loadPreferences()
}
.foregroundColor(Color.appPrimary)
}
.sectionBackground()
} else {
// Task Notifications
Section {
Toggle(isOn: $viewModel.taskDueSoon) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.taskDueSoon)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskDueSoonDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskDueSoon")
.accessibilityHint("Get notified when tasks are due soon")
.onChange(of: viewModel.taskDueSoon) { _, newValue in
guard !isInitialLoad else { return }
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) {
Text(L10n.Profile.taskOverdue)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskOverdueDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskOverdue")
.accessibilityHint("Get notified when tasks are overdue")
.onChange(of: viewModel.taskOverdue) { _, newValue in
guard !isInitialLoad else { return }
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) {
Text(L10n.Profile.taskCompleted)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskCompletedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskCompleted")
.accessibilityHint("Get notified when tasks are completed by others")
.onChange(of: viewModel.taskCompleted) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskCompleted: newValue)
}
Toggle(isOn: $viewModel.taskAssigned) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.taskAssigned)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.taskAssignedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "person.badge.plus.fill")
.foregroundColor(Color.appSecondary)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.TaskAssigned")
.accessibilityHint("Get notified when tasks are assigned to you")
.onChange(of: viewModel.taskAssigned) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(taskAssigned: newValue)
}
} header: {
Text(L10n.Profile.taskNotifications)
}
.sectionBackground()
// Other Notifications
Section {
Toggle(isOn: $viewModel.residenceShared) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.propertyShared)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.propertySharedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image("outline")
.resizable()
.frame(width: 22, height: 22)
.foregroundColor(Color.appTextOnPrimary)
.background(content: {
RoundedRectangle(cornerRadius: AppRadius.sm)
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 22, height: 22)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3)
})
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.ResidenceShared")
.accessibilityHint("Get notified when someone joins your property")
.onChange(of: viewModel.residenceShared) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(residenceShared: newValue)
}
Toggle(isOn: $viewModel.warrantyExpiring) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.warrantyExpiring)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.warrantyExpiringDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "doc.badge.clock.fill")
.foregroundColor(Color.appAccent)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.WarrantyExpiring")
.accessibilityHint("Get notified when warranties are about to expire")
.onChange(of: viewModel.warrantyExpiring) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(warrantyExpiring: newValue)
}
Toggle(isOn: $viewModel.dailyDigest) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.dailyDigest)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.dailyDigestDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "list.bullet.clipboard.fill")
.foregroundColor(Color.appSecondary)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.DailyDigest")
.accessibilityHint("Receive a daily summary of upcoming tasks")
.onChange(of: viewModel.dailyDigest) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(dailyDigest: newValue)
}
// Time picker for Daily Digest
if viewModel.dailyDigest {
NotificationTimePickerRow(
isEnabled: $viewModel.dailyDigestTimeEnabled,
selectedHour: $viewModel.dailyDigestHour,
onEnableCustomTime: {
viewModel.enableCustomTime(for: .dailyDigest)
},
onTimeChange: { hour in
viewModel.updateCustomTime(hour, for: .dailyDigest)
},
formatHour: viewModel.formatHour
)
}
} header: {
Text(L10n.Profile.otherNotifications)
}
.sectionBackground()
// Email Notifications
Section {
Toggle(isOn: $viewModel.emailTaskCompleted) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.Profile.emailTaskCompleted)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Profile.emailTaskCompletedDescription)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
} icon: {
Image(systemName: "envelope.fill")
.foregroundColor(Color.appPrimary)
}
}
.tint(Color.appPrimary)
.accessibilityIdentifier("Notifications.EmailTaskCompleted")
.accessibilityHint("Receive email notifications when tasks are completed")
.onChange(of: viewModel.emailTaskCompleted) { _, newValue in
guard !isInitialLoad else { return }
viewModel.updatePreference(emailTaskCompleted: newValue)
}
} header: {
Text(L10n.Profile.emailNotifications)
}
.sectionBackground()
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.clear)
}
.navigationTitle(L10n.Profile.notifications)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.done) {
dismiss()
}
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
}
}
.onAppear {
// Track screen view
AnalyticsManager.shared.trackScreen(.notificationSettings)
viewModel.loadPreferences()
}
.onChange(of: viewModel.isLoading) { _, newValue in
// Clear the initial load guard once preferences have finished loading
if !newValue && isInitialLoad {
isInitialLoad = false
}
}
}
}
// MARK: - ViewModel Wrapper
@MainActor
class NotificationPreferencesViewModelWrapper: ObservableObject {
@Published var taskDueSoon: Bool = true
@Published var taskOverdue: Bool = true
@Published var taskCompleted: Bool = true
@Published var taskAssigned: Bool = true
@Published var residenceShared: Bool = true
@Published var warrantyExpiring: Bool = true
@Published var dailyDigest: 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
@Published var dailyDigestHour: 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 dailyDigestTimeEnabled: 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
private let defaultDailyDigestLocalHour = 8 // 8 AM local
func loadPreferences() {
isLoading = true
errorMessage = nil
Task {
do {
let result = try await APILayer.shared.getNotificationPreferences()
if let success = result as? ApiResultSuccess<NotificationPreference>, let prefs = success.data {
self.taskDueSoon = prefs.taskDueSoon
self.taskOverdue = prefs.taskOverdue
self.taskCompleted = prefs.taskCompleted
self.taskAssigned = prefs.taskAssigned
self.residenceShared = prefs.residenceShared
self.warrantyExpiring = prefs.warrantyExpiring
self.dailyDigest = prefs.dailyDigest
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
}
if let utcHour = prefs.dailyDigestHour?.intValue {
self.dailyDigestHour = DateUtils.utcHourToLocal(Int(utcHour))
self.dailyDigestTimeEnabled = true
}
self.isLoading = false
self.errorMessage = nil
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isLoading = false
} else {
self.errorMessage = "Failed to load notification preferences"
self.isLoading = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isLoading = false
}
}
}
func updatePreference(
taskDueSoon: Bool? = nil,
taskOverdue: Bool? = nil,
taskCompleted: Bool? = nil,
taskAssigned: Bool? = nil,
residenceShared: Bool? = nil,
warrantyExpiring: Bool? = nil,
dailyDigest: Bool? = nil,
emailTaskCompleted: Bool? = nil,
taskDueSoonHour: Int? = nil,
taskOverdueHour: Int? = nil,
warrantyExpiringHour: Int? = nil,
dailyDigestHour: 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 dailyDigestUtc = dailyDigestHour.map { DateUtils.localHourToUtc($0) }
let request = UpdateNotificationPreferencesRequest(
taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) },
taskOverdue: taskOverdue.map { KotlinBoolean(bool: $0) },
taskCompleted: taskCompleted.map { KotlinBoolean(bool: $0) },
taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) },
residenceShared: residenceShared.map { KotlinBoolean(bool: $0) },
warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) },
dailyDigest: dailyDigest.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)) },
dailyDigestHour: dailyDigestUtc.map { KotlinInt(int: Int32($0)) }
)
let result = try await APILayer.shared.updateNotificationPreferences(request: request)
if result is ApiResultSuccess<NotificationPreference> {
self.isSaving = false
} else if let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
self.isSaving = false
} else {
self.errorMessage = "Failed to save notification preferences"
self.isSaving = false
}
} catch {
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
self.isSaving = false
}
}
}
// 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)
case .dailyDigest:
dailyDigestHour = defaultDailyDigestLocalHour
dailyDigestTimeEnabled = true
updatePreference(dailyDigestHour: defaultDailyDigestLocalHour)
}
}
// 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)
case .dailyDigest:
dailyDigestHour = hour
updatePreference(dailyDigestHour: hour)
}
}
enum NotificationType {
case taskDueSoon
case taskOverdue
case warrantyExpiring
case dailyDigest
}
// 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
}
)
}
}
}
// MARK: - TimePickerSheet
struct TimePickerSheet: View {
@State private var selectedHour: Int
@State private var isPresented: Bool = true
let onSave: (Int) -> Void
let onCancel: () -> Void
// Pre-computed hour labels as a simple struct for stable identity
private struct HourOption: Identifiable {
let id: Int
let label: String
var hour: Int { id }
}
private static let hourOptions: [HourOption] = (0..<24).map { hour in
let label: String
switch hour {
case 0: label = "12:00 AM"
case 1...11: label = "\(hour):00 AM"
case 12: label = "12:00 PM"
default: label = "\(hour - 12):00 PM"
}
return HourOption(id: hour, label: label)
}
init(selectedHour: Int, onSave: @escaping (Int) -> Void, onCancel: @escaping () -> Void) {
_selectedHour = State(initialValue: selectedHour)
self.onSave = onSave
self.onCancel = onCancel
}
private func formatHour(_ hour: Int) -> String {
Self.hourOptions.first { $0.hour == hour }?.label ?? "\(hour):00"
}
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Text("Select Notification Time")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
.padding(.top)
if isPresented {
Picker("Hour", selection: $selectedHour) {
ForEach(Self.hourOptions) { option in
Text(option.label)
.tag(option.hour)
}
}
.pickerStyle(.wheel)
.frame(height: 150)
.id("hourPicker") // Stable identity to prevent view recycling issues
}
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") {
// Hide picker before dismissing to prevent race condition
isPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onCancel()
}
}
.foregroundColor(Color.appTextSecondary)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
// Hide picker before dismissing to prevent race condition
isPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onSave(selectedHour)
}
}
.foregroundColor(Color.appPrimary)
.fontWeight(.semibold)
}
}
}
.presentationDetents([.medium])
.interactiveDismissDisabled() // Prevent swipe-to-dismiss which can cause race condition
}
}
#Preview {
NotificationPreferencesView()
}