feat(testflight): add MetricKit crash reporting + feedback form in Settings

This commit is contained in:
saravanakumardb1 2026-02-27 22:24:00 -08:00
parent 01c0f5759e
commit e87e027c0f
2 changed files with 335 additions and 0 deletions

View File

@ -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<UnitDuration>) {
// 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
}
}

View File

@ -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 }
}
}