608 lines
27 KiB
Swift
608 lines
27 KiB
Swift
// ── 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 }
|
|
}
|
|
}
|