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
722 lines
32 KiB
Swift
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()
|
|
}
|