feat(testflight): add MetricKit crash reporting + feedback form in Settings
This commit is contained in:
parent
01c0f5759e
commit
e87e027c0f
152
ios/ChronoMind/Shared/Diagnostics/CrashReporter.swift
Normal file
152
ios/ChronoMind/Shared/Diagnostics/CrashReporter.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,11 +6,14 @@ import SwiftUI
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var store: TimerStore
|
@EnvironmentObject var store: TimerStore
|
||||||
@EnvironmentObject var notificationManager: CMNotificationManager
|
@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_defaultUrgency") private var defaultUrgency = "standard"
|
||||||
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
@AppStorage("cm_defaultCascade") private var defaultCascade = "standard"
|
||||||
@AppStorage("cm_hapticEnabled") private var hapticEnabled = true
|
@AppStorage("cm_hapticEnabled") private var hapticEnabled = true
|
||||||
@AppStorage("cm_soundEnabled") private var soundEnabled = true
|
@AppStorage("cm_soundEnabled") private var soundEnabled = true
|
||||||
|
@State private var showFeedback = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@ -118,6 +121,59 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(CMColors.surface)
|
.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
|
// About
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
@ -155,6 +211,133 @@ struct SettingsView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
.toolbarBackground(CMColors.surface, for: .navigationBar)
|
||||||
.toolbarColorScheme(.dark, 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user