feat(gdpr): add data export (JSON) + account deletion — App Store and GDPR compliance
This commit is contained in:
parent
3df8ac597b
commit
9be48f0abf
210
ios/ChronoMind/Shared/Data/DataExportManager.swift
Normal file
210
ios/ChronoMind/Shared/Data/DataExportManager.swift
Normal file
@ -0,0 +1,210 @@
|
||||
// ── Data Export & Account Deletion Manager ────────────────────
|
||||
// GDPR + App Store requirement: export all data, delete account
|
||||
// All data is local — export as JSON, delete clears everything
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class DataExportManager: ObservableObject {
|
||||
static let shared = DataExportManager()
|
||||
|
||||
@Published var isExporting = false
|
||||
@Published var isDeleting = false
|
||||
@Published var lastExportURL: URL?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Export All Data
|
||||
|
||||
func exportAllData(
|
||||
timers: [CMTimer],
|
||||
moodCheckIns: [MoodCheckIn],
|
||||
sleepData: SleepSummary?,
|
||||
bedtimeRoutine: BedtimeRoutine?,
|
||||
savedLocations: [SavedLocation],
|
||||
locationTriggers: [LocationTrigger]
|
||||
) -> URL? {
|
||||
isExporting = true
|
||||
defer { isExporting = false }
|
||||
|
||||
let export = DataExport(
|
||||
exportDate: Date(),
|
||||
appVersion: "1.0.0",
|
||||
dataVersion: 1,
|
||||
timers: timers.map { TimerExport(from: $0) },
|
||||
moodCheckIns: moodCheckIns,
|
||||
sleepSummary: sleepData,
|
||||
bedtimeRoutine: bedtimeRoutine,
|
||||
savedLocations: savedLocations,
|
||||
locationTriggers: locationTriggers,
|
||||
statistics: generateStatistics(timers: timers, checkIns: moodCheckIns)
|
||||
)
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(export)
|
||||
|
||||
let fileName = "chronomind-export-\(ISO8601DateFormatter().string(from: Date())).json"
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||
try data.write(to: tempURL)
|
||||
|
||||
lastExportURL = tempURL
|
||||
return tempURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete All Data
|
||||
|
||||
func deleteAllData() {
|
||||
isDeleting = true
|
||||
|
||||
// Clear all UserDefaults keys
|
||||
let keysToRemove = [
|
||||
"chronomind-timers",
|
||||
"chronomind-timers-cloud",
|
||||
"chronomind-cloud-sync-enabled",
|
||||
"chronomind-last-sync",
|
||||
"chronomind-mood-checkins",
|
||||
"chronomind-mood-enabled",
|
||||
"chronomind-bedtime-routine",
|
||||
"chronomind-smart-alarm",
|
||||
"chronomind-last-sleep",
|
||||
"chronomind-saved-locations",
|
||||
"chronomind-location-triggers",
|
||||
"chronomind-synced-calendars",
|
||||
"chronomind-calendar-sync-enabled",
|
||||
"chronomind-calendar-last-sync",
|
||||
"chronomind-crash-reports",
|
||||
"chronomind-feedback",
|
||||
"chronomind-gamification-streaks",
|
||||
"chronomind-gamification-badges",
|
||||
"chronomind-gamification-scores",
|
||||
"cm_defaultUrgency",
|
||||
"cm_defaultCascade",
|
||||
"cm_hapticEnabled",
|
||||
"cm_soundEnabled",
|
||||
]
|
||||
|
||||
for key in keysToRemove {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
// Clear shared App Group defaults
|
||||
if let sharedDefaults = UserDefaults(suiteName: "group.com.chronomind.shared") {
|
||||
for key in keysToRemove {
|
||||
sharedDefaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear iCloud KV store
|
||||
let kvStore = NSUbiquitousKeyValueStore.default
|
||||
for key in kvStore.dictionaryRepresentation.keys {
|
||||
if key.hasPrefix("chronomind") {
|
||||
kvStore.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
kvStore.synchronize()
|
||||
|
||||
// Remove all pending notifications
|
||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||
|
||||
// Clear temp files
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
if let contents = try? FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) {
|
||||
for url in contents where url.lastPathComponent.hasPrefix("chronomind") {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
isDeleting = false
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
private func generateStatistics(timers: [CMTimer], checkIns: [MoodCheckIn]) -> ExportStatistics {
|
||||
let completed = timers.filter { $0.state == .completed }
|
||||
let dismissed = timers.filter { $0.state == .dismissed }
|
||||
|
||||
let avgEnergy: Double? = checkIns.isEmpty ? nil :
|
||||
Double(checkIns.reduce(0) { $0 + $1.energy.numericValue }) / Double(checkIns.count)
|
||||
|
||||
return ExportStatistics(
|
||||
totalTimersCreated: timers.count,
|
||||
timersCompleted: completed.count,
|
||||
timersDismissed: dismissed.count,
|
||||
totalSnoozes: timers.reduce(0) { $0 + $1.snoozeCount },
|
||||
totalMoodCheckIns: checkIns.count,
|
||||
averageEnergyLevel: avgEnergy,
|
||||
firstTimerDate: timers.map(\.createdAt).min(),
|
||||
lastTimerDate: timers.map(\.createdAt).max()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import support
|
||||
|
||||
import UserNotifications
|
||||
|
||||
// MARK: - Export Models
|
||||
|
||||
struct DataExport: Codable {
|
||||
let exportDate: Date
|
||||
let appVersion: String
|
||||
let dataVersion: Int
|
||||
let timers: [TimerExport]
|
||||
let moodCheckIns: [MoodCheckIn]
|
||||
let sleepSummary: SleepSummary?
|
||||
let bedtimeRoutine: BedtimeRoutine?
|
||||
let savedLocations: [SavedLocation]
|
||||
let locationTriggers: [LocationTrigger]
|
||||
let statistics: ExportStatistics
|
||||
}
|
||||
|
||||
struct TimerExport: Codable {
|
||||
let id: String
|
||||
let label: String
|
||||
let type: String
|
||||
let state: String
|
||||
let urgency: String
|
||||
let duration: TimeInterval
|
||||
let targetTime: Date
|
||||
let createdAt: Date
|
||||
let completedAt: Date?
|
||||
let dismissedAt: Date?
|
||||
let snoozeCount: Int
|
||||
let category: String?
|
||||
let cascadePreset: String
|
||||
|
||||
init(from timer: CMTimer) {
|
||||
self.id = timer.id
|
||||
self.label = timer.label
|
||||
self.type = timer.type.rawValue
|
||||
self.state = timer.state.rawValue
|
||||
self.urgency = timer.urgency.rawValue
|
||||
self.duration = timer.duration
|
||||
self.targetTime = timer.targetTime
|
||||
self.createdAt = timer.createdAt
|
||||
self.completedAt = timer.completedAt
|
||||
self.dismissedAt = timer.dismissedAt
|
||||
self.snoozeCount = timer.snoozeCount
|
||||
self.category = timer.category
|
||||
self.cascadePreset = timer.cascade.preset.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
struct ExportStatistics: Codable {
|
||||
let totalTimersCreated: Int
|
||||
let timersCompleted: Int
|
||||
let timersDismissed: Int
|
||||
let totalSnoozes: Int
|
||||
let totalMoodCheckIns: Int
|
||||
let averageEnergyLevel: Double?
|
||||
let firstTimerDate: Date?
|
||||
let lastTimerDate: Date?
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user