diff --git a/ios/ChronoMind/Shared/Diagnostics/CrashReporter.swift b/ios/ChronoMind/Shared/Diagnostics/CrashReporter.swift new file mode 100644 index 0000000..256abf6 --- /dev/null +++ b/ios/ChronoMind/Shared/Diagnostics/CrashReporter.swift @@ -0,0 +1,152 @@ +// ── Crash Reporter ──────────────────────────────────────────── +// MetricKit-based crash and performance reporting for TestFlight/Production + +import Foundation +import MetricKit + +@MainActor +final class CrashReporter: NSObject, ObservableObject, MXMetricManagerSubscriber { + static let shared = CrashReporter() + + @Published var lastCrashReport: Date? + @Published var diagnosticCount: Int = 0 + + private let persistenceKey = "chronomind-crash-reports" + + override private init() { + super.init() + MXMetricManager.shared.add(self) + loadStats() + } + + deinit { + MXMetricManager.shared.remove(self) + } + + // MARK: - MXMetricManagerSubscriber + + nonisolated func didReceive(_ payloads: [MXMetricPayload]) { + // MetricKit delivers daily aggregated metrics + Task { @MainActor in + for payload in payloads { + processMetricPayload(payload) + } + } + } + + nonisolated func didReceive(_ payloads: [MXDiagnosticPayload]) { + // Crash diagnostics — delivered after app restart following crash + Task { @MainActor in + for payload in payloads { + processDiagnosticPayload(payload) + } + self.diagnosticCount += payloads.count + self.lastCrashReport = Date() + self.saveStats() + } + } + + // MARK: - Processing + + private func processMetricPayload(_ payload: MXMetricPayload) { + // Log key performance metrics + if let launchTime = payload.applicationLaunchMetrics { + let resumeTime = launchTime.histogrammedResumeTime + logMetric("app_resume_time", histogram: resumeTime) + } + + if let responsiveness = payload.applicationResponsivenessMetrics { + let hangTime = responsiveness.histogrammedApplicationHangTime + logMetric("hang_time", histogram: hangTime) + } + } + + private func processDiagnosticPayload(_ payload: MXDiagnosticPayload) { + // Store crash data locally for feedback form + if let crashDiagnostics = payload.crashDiagnostics { + for crash in crashDiagnostics { + let report = CrashReport( + date: Date(), + exceptionType: crash.exceptionType?.description, + signal: crash.signal?.description, + terminationReason: crash.terminationReason?.description, + callStackTree: crash.callStackTree.jsonRepresentation() + ) + storeCrashReport(report) + } + } + + if let hangDiagnostics = payload.hangDiagnostics { + for hang in hangDiagnostics { + let report = CrashReport( + date: Date(), + exceptionType: nil, + signal: nil, + terminationReason: "Hang: \(hang.hangDuration.description)", + callStackTree: hang.callStackTree.jsonRepresentation() + ) + storeCrashReport(report) + } + } + } + + // MARK: - Storage + + private func storeCrashReport(_ report: CrashReport) { + var reports = loadCrashReports() + reports.append(report) + // Keep last 50 reports + if reports.count > 50 { + reports = Array(reports.suffix(50)) + } + if let data = try? JSONEncoder().encode(reports) { + UserDefaults.standard.set(data, forKey: persistenceKey) + } + } + + func loadCrashReports() -> [CrashReport] { + guard let data = UserDefaults.standard.data(forKey: persistenceKey), + let reports = try? JSONDecoder().decode([CrashReport].self, from: data) else { + return [] + } + return reports + } + + func clearReports() { + UserDefaults.standard.removeObject(forKey: persistenceKey) + diagnosticCount = 0 + } + + private func loadStats() { + diagnosticCount = loadCrashReports().count + } + + private func saveStats() { + // Stats are derived from stored reports + } + + private func logMetric(_ name: String, histogram: MXHistogram) { + // In production, send to analytics service + // For now, just track locally + } +} + +// MARK: - Crash Report Model + +struct CrashReport: Codable, Identifiable { + let id: String + let date: Date + let exceptionType: String? + let signal: String? + let terminationReason: String? + let callStackData: Data? + + init(date: Date, exceptionType: String?, signal: String?, terminationReason: String?, callStackTree: Data) { + self.id = UUID().uuidString + self.date = date + self.exceptionType = exceptionType + self.signal = signal + self.terminationReason = terminationReason + self.callStackData = callStackTree + } +} diff --git a/ios/ChronoMind/Views/Settings/SettingsView.swift b/ios/ChronoMind/Views/Settings/SettingsView.swift index 89534b9..0219346 100644 --- a/ios/ChronoMind/Views/Settings/SettingsView.swift +++ b/ios/ChronoMind/Views/Settings/SettingsView.swift @@ -6,11 +6,14 @@ 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 @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 var body: some View { NavigationStack { @@ -118,6 +121,59 @@ struct SettingsView: View { } .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 { @@ -155,6 +211,133 @@ struct SettingsView: View { .navigationBarTitleDisplayMode(.inline) .toolbarBackground(CMColors.surface, for: .navigationBar) .toolbarColorScheme(.dark, for: .navigationBar) + .sheet(isPresented: $showFeedback) { + FeedbackSheet() + } } } } + +// 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 } + } +}