// ── Settings View ────────────────────────────────────────────── // Preferences, categories, sounds, about import SwiftUI struct SettingsView: View { @EnvironmentObject var store: TimerStore @EnvironmentObject var notificationManager: CMNotificationManager @ObservedObject private var cloudSync = CloudKitSyncManager.shared @ObservedObject private var crashReporter = CrashReporter.shared @ObservedObject private var authService = CMAuthService.shared @AppStorage("cm_defaultUrgency") private var defaultUrgency = "standard" @AppStorage("cm_defaultCascade") private var defaultCascade = "standard" @AppStorage("cm_hapticEnabled") private var hapticEnabled = true @AppStorage("cm_soundEnabled") private var soundEnabled = true @State private var showFeedback = false @State private var showChangePw = false @State private var changePwCurrent = "" @State private var changePwNew = "" @State private var changePwConfirm = "" @State private var showDeleteAccount = false @State private var deleteConfirmPw = "" @State private var showForgotPw = false @State private var forgotPwEmail = "" @State private var authMessage = "" @State private var authIsError = false @State private var authSubmitting = false @State private var loginEmail = "" @State private var loginPassword = "" @State private var loginName = "" @State private var loginIsRegister = false var body: some View { NavigationStack { ZStack { CMColors.bg.ignoresSafeArea() List { // Account Section { if let user = authService.currentUser { HStack { Label("Email", systemImage: "envelope.fill") .foregroundStyle(CMColors.text) Spacer() Text(user.email) .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.textSecondary) } HStack { Label("Plan", systemImage: "creditcard.fill") .foregroundStyle(CMColors.text) Spacer() Text(user.plan.capitalized) .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.textSecondary) } Button(role: .destructive) { authService.logout() } label: { Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") } if !authMessage.isEmpty { Text(authMessage) .font(CMFonts.body(size: 12)) .foregroundStyle(authIsError ? CMColors.error : CMColors.success) } // Change Password Button { showChangePw.toggle() showDeleteAccount = false authMessage = "" } label: { Label(showChangePw ? "Cancel" : "Change Password", systemImage: "key.fill") .foregroundStyle(CMColors.accent) } if showChangePw { SecureField("Current password", text: $changePwCurrent) .textContentType(.password) SecureField("New password (min 8 chars)", text: $changePwNew) .textContentType(.newPassword) SecureField("Confirm new password", text: $changePwConfirm) .textContentType(.newPassword) if !changePwNew.isEmpty && !changePwConfirm.isEmpty && changePwNew != changePwConfirm { Text("Passwords do not match") .font(CMFonts.body(size: 12)) .foregroundStyle(CMColors.error) } Button { Task { await performChangePassword() } } label: { HStack { Spacer() Text(authSubmitting ? "Updating\u{2026}" : "Update Password") .font(CMFonts.body(size: 14, weight: .semibold)) Spacer() } } .disabled(authSubmitting || changePwCurrent.isEmpty || changePwNew.count < 8 || changePwNew != changePwConfirm) } // Delete Account Button { showDeleteAccount.toggle() showChangePw = false authMessage = "" } label: { Label(showDeleteAccount ? "Cancel" : "Delete Account", systemImage: "trash.fill") .foregroundStyle(CMColors.error) } if showDeleteAccount { Text("This action is permanent. Enter your password to confirm.") .font(CMFonts.body(size: 12)) .foregroundStyle(CMColors.textMuted) SecureField("Your password", text: $deleteConfirmPw) .textContentType(.password) Button(role: .destructive) { Task { await performDeleteAccount() } } label: { HStack { Spacer() Text(authSubmitting ? "Deleting\u{2026}" : "Permanently Delete Account") .font(CMFonts.body(size: 14, weight: .semibold)) Spacer() } } .disabled(authSubmitting || deleteConfirmPw.isEmpty) } } else { // Login / Register form Text("Sign in to sync timers across devices") .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.textSecondary) // Mode toggle Picker("", selection: $loginIsRegister) { Text("Sign In").tag(false) Text("Register").tag(true) } .pickerStyle(.segmented) .onChange(of: loginIsRegister) { _, _ in authMessage = "" } if loginIsRegister { TextField("Display Name", text: $loginName) .textContentType(.name) .autocapitalization(.words) } TextField("Email", text: $loginEmail) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) SecureField("Password", text: $loginPassword) .textContentType(loginIsRegister ? .newPassword : .password) if !authMessage.isEmpty { Text(authMessage) .font(CMFonts.body(size: 12)) .foregroundStyle(authIsError ? CMColors.error : CMColors.success) } Button { Task { await performLogin() } } label: { HStack { Spacer() if authSubmitting { ProgressView().tint(.white) } else { Text(loginIsRegister ? "Create Account" : "Sign In") .font(CMFonts.body(size: 14, weight: .semibold)) } Spacer() } } .disabled(authSubmitting || loginEmail.isEmpty || loginPassword.count < 8 || (loginIsRegister && loginName.isEmpty)) // Forgot Password Button { showForgotPw.toggle() authMessage = "" } label: { Label(showForgotPw ? "Cancel" : "Forgot Password?", systemImage: "questionmark.circle") .foregroundStyle(CMColors.textSecondary) .font(CMFonts.body(size: 13)) } if showForgotPw { TextField("Email", text: $forgotPwEmail) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) Button { Task { await performForgotPassword() } } label: { HStack { Spacer() Text(authSubmitting ? "Sending\u{2026}" : "Send Reset Link") .font(CMFonts.body(size: 14, weight: .semibold)) Spacer() } } .disabled(authSubmitting || forgotPwEmail.isEmpty) } } } header: { Text("Account") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) // Notifications Section { HStack { Label("Notifications", systemImage: "bell.fill") .foregroundStyle(CMColors.text) Spacer() if notificationManager.isAuthorized { Text("Enabled") .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.success) } else { Button("Enable") { Task { await notificationManager.requestPermission() } } .font(CMFonts.body(size: 13, weight: .semibold)) .foregroundStyle(CMColors.accent) } } Toggle(isOn: $hapticEnabled) { Label("Haptic Feedback", systemImage: "iphone.radiowaves.left.and.right") .foregroundStyle(CMColors.text) } .tint(CMColors.accent) Toggle(isOn: $soundEnabled) { Label("Sound", systemImage: "speaker.wave.2.fill") .foregroundStyle(CMColors.text) } .tint(CMColors.accent) } header: { Text("Notifications & Feedback") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) // Defaults Section { Picker(selection: $defaultUrgency) { ForEach(UrgencyLevel.allCases) { level in HStack { Circle() .fill(CMColors.urgencyColor(level)) .frame(width: 8, height: 8) Text(getUrgencyConfig(level).label) } .tag(level.rawValue) } } label: { Label("Default Urgency", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(CMColors.text) } Picker(selection: $defaultCascade) { ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in Text(preset.label).tag(preset.rawValue) } } label: { Label("Default Cascade", systemImage: "arrow.down.forward.and.arrow.up.backward") .foregroundStyle(CMColors.text) } } header: { Text("Defaults") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) // Data Section { HStack { Label("Total Timers", systemImage: "number") .foregroundStyle(CMColors.text) Spacer() Text("\(store.timers.count)") .font(CMFonts.mono(size: 14)) .foregroundStyle(CMColors.textSecondary) } Button { store.timers.removeAll { [.completed, .dismissed].contains($0.state) } } label: { Label("Clear History", systemImage: "trash") .foregroundStyle(CMColors.error) } Button { store.timers.removeAll() CMNotificationManager.shared.removeAllNotifications() } label: { Label("Delete All Timers", systemImage: "trash.fill") .foregroundStyle(CMColors.error) } } header: { Text("Data") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) // iCloud Sync Section { Toggle(isOn: $cloudSync.syncEnabled) { Label("iCloud Sync", systemImage: "icloud.fill") .foregroundStyle(CMColors.text) } .tint(CMColors.accent) if cloudSync.syncEnabled { HStack { Label("Last Synced", systemImage: "arrow.triangle.2.circlepath") .foregroundStyle(CMColors.text) Spacer() if let date = cloudSync.lastSyncDate { Text(formatRelativeTime(date, now: Date())) .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.textMuted) } else { Text("Never") .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.textMuted) } } } } header: { Text("Sync") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) // Feedback & Diagnostics Section { Button { showFeedback = true } label: { Label("Send Feedback", systemImage: "envelope.fill") .foregroundStyle(CMColors.accent) } HStack { Label("Crash Reports", systemImage: "exclamationmark.triangle") .foregroundStyle(CMColors.text) Spacer() Text("\(crashReporter.diagnosticCount)") .font(CMFonts.mono(size: 13)) .foregroundStyle(CMColors.textMuted) } } header: { Text("Feedback & Diagnostics") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) // About Section { HStack { Label("Version", systemImage: "info.circle") .foregroundStyle(CMColors.text) Spacer() Text("1.0.0 (Phase 3)") .font(CMFonts.body(size: 13)) .foregroundStyle(CMColors.textMuted) } HStack { Label("Product ID", systemImage: "barcode") .foregroundStyle(CMColors.text) Spacer() Text("chronomind") .font(CMFonts.mono(size: 13)) .foregroundStyle(CMColors.textMuted) } Link(destination: URL(string: "https://chronomind.app/privacy")!) { Label("Privacy Policy", systemImage: "hand.raised.fill") .foregroundStyle(CMColors.text) } } header: { Text("About") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) } .scrollContentBackground(.hidden) .listStyle(.insetGrouped) } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(CMColors.surface, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .sheet(isPresented: $showFeedback) { FeedbackSheet() } } } // MARK: - Auth Actions private func performLogin() async { authSubmitting = true authMessage = "" if loginIsRegister { await authService.register(name: loginName, email: loginEmail, password: loginPassword) } else { await authService.login(email: loginEmail, password: loginPassword) } authSubmitting = false if case .error(let msg) = authService.state { authMessage = msg authIsError = true } else { loginEmail = "" loginPassword = "" loginName = "" } } private func performChangePassword() async { authSubmitting = true authMessage = "" let err = await authService.changePassword(currentPassword: changePwCurrent, newPassword: changePwNew) authSubmitting = false if let err { authMessage = err authIsError = true } else { authMessage = "Password changed successfully." authIsError = false changePwCurrent = "" changePwNew = "" changePwConfirm = "" showChangePw = false } } private func performDeleteAccount() async { authSubmitting = true authMessage = "" let err = await authService.deleteAccount(password: deleteConfirmPw) authSubmitting = false if let err { authMessage = err authIsError = true } else { deleteConfirmPw = "" showDeleteAccount = false } } private func performForgotPassword() async { authSubmitting = true authMessage = "" let err = await authService.forgotPassword(email: forgotPwEmail) authSubmitting = false if let err { authMessage = err authIsError = true } else { authMessage = "If that email exists, a reset link has been sent." authIsError = false } } } // MARK: - Feedback Sheet struct FeedbackSheet: View { @Environment(\.dismiss) private var dismiss @State private var feedbackType: FeedbackType = .bug @State private var feedbackText = "" @State private var includeDeviceInfo = true @State private var submitted = false enum FeedbackType: String, CaseIterable, Identifiable { case bug = "Bug Report" case feature = "Feature Request" case general = "General Feedback" var id: String { rawValue } } var body: some View { NavigationStack { ZStack { CMColors.bg.ignoresSafeArea() if submitted { VStack(spacing: CMSpacing.lg) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 60)) .foregroundStyle(CMColors.success) Text("Thanks for your feedback!") .font(CMFonts.display(size: 20)) .foregroundStyle(CMColors.text) Text("We'll review it and get back to you.") .font(CMFonts.body(size: 14)) .foregroundStyle(CMColors.textSecondary) } } else { List { Section { Picker("Type", selection: $feedbackType) { ForEach(FeedbackType.allCases) { type in Text(type.rawValue).tag(type) } } .pickerStyle(.segmented) } .listRowBackground(CMColors.surface) Section { TextEditor(text: $feedbackText) .frame(minHeight: 150) .scrollContentBackground(.hidden) .foregroundStyle(CMColors.text) } header: { Text("Describe your feedback") .foregroundStyle(CMColors.textMuted) } .listRowBackground(CMColors.surface) Section { Toggle(isOn: $includeDeviceInfo) { VStack(alignment: .leading, spacing: CMSpacing.xxs) { Text("Include device info") .foregroundStyle(CMColors.text) Text("iOS version, device model, app version") .font(CMFonts.body(size: 11)) .foregroundStyle(CMColors.textMuted) } } .tint(CMColors.accent) } .listRowBackground(CMColors.surface) Section { Button { submitFeedback() } label: { HStack { Spacer() Text("Submit Feedback") .font(CMFonts.body(size: 16, weight: .semibold)) .foregroundStyle(.white) Spacer() } .padding(.vertical, CMSpacing.sm) .background(feedbackText.isEmpty ? CMColors.textMuted : CMColors.accent) .clipShape(RoundedRectangle(cornerRadius: CMRadius.sm)) } .disabled(feedbackText.isEmpty) } .listRowBackground(Color.clear) } .scrollContentBackground(.hidden) .listStyle(.insetGrouped) } } .navigationTitle("Send Feedback") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(CMColors.surface, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(submitted ? "Done" : "Cancel") { dismiss() } .foregroundStyle(CMColors.textSecondary) } } } } private func submitFeedback() { // Store feedback locally (send to server in production) let feedback: [String: Any] = [ "type": feedbackType.rawValue, "text": feedbackText, "date": Date().timeIntervalSince1970, "includeDeviceInfo": includeDeviceInfo, ] var stored = UserDefaults.standard.array(forKey: "chronomind-feedback") as? [[String: Any]] ?? [] stored.append(feedback) UserDefaults.standard.set(stored, forKey: "chronomind-feedback") HapticEngine.tap() withAnimation { submitted = true } } }