import Combine import Foundation import SwiftUI import UniformTypeIdentifiers #if canImport(WidgetKit) import WidgetKit #endif #if canImport(ActivityKit) import ActivityKit #endif @MainActor class ClimbingDataManager: ObservableObject { @Published var gyms: [Gym] = [] @Published var problems: [Problem] = [] @Published var sessions: [ClimbSession] = [] @Published var attempts: [Attempt] = [] @Published var activeSession: ClimbSession? @Published var isLoading = false @Published var errorMessage: String? @Published var successMessage: String? private let userDefaults = UserDefaults.standard private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let encoder = JSONEncoder() private let decoder = JSONDecoder() private var liveActivityObserver: NSObjectProtocol? // Sync service for automatic syncing let syncService = SyncService() // Published property to propagate sync state changes @Published var isSyncing = false private enum Keys { static let gyms = "openclimb_gyms" static let problems = "openclimb_problems" static let sessions = "openclimb_sessions" static let attempts = "openclimb_attempts" static let activeSession = "openclimb_active_session" } // Widget data models private struct WidgetAttempt: Codable { let id: String let sessionId: String let problemId: String let timestamp: Date let result: String } private struct WidgetSession: Codable { let id: String let gymId: String let date: Date let status: String } private struct WidgetGym: Codable { let id: String let name: String } init() { _ = ImageManager.shared loadAllData() migrateImagePaths() setupLiveActivityNotifications() // Keep our published isSyncing in sync with syncService.isSyncing syncService.$isSyncing .assign(to: &$isSyncing) Task { try? await Task.sleep(nanoseconds: 2_000_000_000) await performImageMaintenance() // Check if we need to restart Live Activity for active session await checkAndRestartLiveActivity() } } deinit { if let observer = liveActivityObserver { NotificationCenter.default.removeObserver(observer) } } private func loadAllData() { loadGyms() loadProblems() loadSessions() loadAttempts() loadActiveSession() } private func loadGyms() { if let data = userDefaults.data(forKey: Keys.gyms), let loadedGyms = try? decoder.decode([Gym].self, from: data) { self.gyms = loadedGyms } } private func loadProblems() { if let data = userDefaults.data(forKey: Keys.problems), let loadedProblems = try? decoder.decode([Problem].self, from: data) { self.problems = loadedProblems } } private func loadSessions() { if let data = userDefaults.data(forKey: Keys.sessions), let loadedSessions = try? decoder.decode([ClimbSession].self, from: data) { self.sessions = loadedSessions } } private func loadAttempts() { if let data = userDefaults.data(forKey: Keys.attempts), let loadedAttempts = try? decoder.decode([Attempt].self, from: data) { self.attempts = loadedAttempts } } private func loadActiveSession() { if let data = userDefaults.data(forKey: Keys.activeSession), let loadedActiveSession = try? decoder.decode(ClimbSession.self, from: data) { self.activeSession = loadedActiveSession } } private func saveGyms() { if let data = try? encoder.encode(gyms) { userDefaults.set(data, forKey: Keys.gyms) // Share with widget - convert to widget format let widgetGyms = gyms.map { gym in WidgetGym(id: gym.id.uuidString, name: gym.name) } if let widgetData = try? encoder.encode(widgetGyms) { sharedUserDefaults?.set(widgetData, forKey: Keys.gyms) } } } private func saveProblems() { if let data = try? encoder.encode(problems) { userDefaults.set(data, forKey: Keys.problems) // Share with widget sharedUserDefaults?.set(data, forKey: Keys.problems) } } internal func saveSessions() { if let data = try? encoder.encode(sessions) { userDefaults.set(data, forKey: Keys.sessions) // Share with widget - convert to widget format let widgetSessions = sessions.map { session in WidgetSession( id: session.id.uuidString, gymId: session.gymId.uuidString, date: session.date, status: session.status.rawValue ) } if let widgetData = try? encoder.encode(widgetSessions) { sharedUserDefaults?.set(widgetData, forKey: Keys.sessions) } } } internal func saveAttempts() { if let data = try? encoder.encode(attempts) { userDefaults.set(data, forKey: Keys.attempts) // Share with widget - convert to widget format let widgetAttempts = attempts.map { attempt in WidgetAttempt( id: attempt.id.uuidString, sessionId: attempt.sessionId.uuidString, problemId: attempt.problemId.uuidString, timestamp: attempt.timestamp, result: attempt.result.rawValue ) } if let widgetData = try? encoder.encode(widgetAttempts) { sharedUserDefaults?.set(widgetData, forKey: Keys.attempts) } // Update widget timeline updateWidgetTimeline() } } internal func saveActiveSession() { if let activeSession = activeSession, let data = try? encoder.encode(activeSession) { userDefaults.set(data, forKey: Keys.activeSession) } else { userDefaults.removeObject(forKey: Keys.activeSession) } } func addGym(_ gym: Gym) { gyms.append(gym) saveGyms() DataStateManager.shared.updateDataState() successMessage = "Gym added successfully" clearMessageAfterDelay() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } func updateGym(_ gym: Gym) { if let index = gyms.firstIndex(where: { $0.id == gym.id }) { gyms[index] = gym saveGyms() DataStateManager.shared.updateDataState() successMessage = "Gym updated successfully" clearMessageAfterDelay() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } } func deleteGym(_ gym: Gym) { // Delete associated problems and their attempts first let problemsToDelete = problems.filter { $0.gymId == gym.id } for problem in problemsToDelete { deleteProblem(problem) } // Delete associated sessions and their attempts let sessionsToDelete = sessions.filter { $0.gymId == gym.id } for session in sessionsToDelete { deleteSession(session) } // Delete the gym gyms.removeAll { $0.id == gym.id } saveGyms() DataStateManager.shared.updateDataState() successMessage = "Gym deleted successfully" clearMessageAfterDelay() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } func gym(withId id: UUID) -> Gym? { return gyms.first { $0.id == id } } func addProblem(_ problem: Problem) { problems.append(problem) saveProblems() DataStateManager.shared.updateDataState() successMessage = "Problem added successfully" clearMessageAfterDelay() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } func updateProblem(_ problem: Problem) { if let index = problems.firstIndex(where: { $0.id == problem.id }) { problems[index] = problem saveProblems() DataStateManager.shared.updateDataState() successMessage = "Problem updated successfully" clearMessageAfterDelay() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } } func deleteProblem(_ problem: Problem) { // Delete associated attempts first attempts.removeAll { $0.problemId == problem.id } saveAttempts() // Delete associated images ImageManager.shared.deleteImages(atPaths: problem.imagePaths) // Delete the problem problems.removeAll { $0.id == problem.id } saveProblems() DataStateManager.shared.updateDataState() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } func problem(withId id: UUID) -> Problem? { return problems.first { $0.id == id } } func problems(forGym gymId: UUID) -> [Problem] { return problems.filter { $0.gymId == gymId } } func activeProblems(forGym gymId: UUID) -> [Problem] { return problems.filter { $0.gymId == gymId && $0.isActive } } func startSession(gymId: UUID, notes: String? = nil) { // End any currently active session if let currentActive = activeSession { endSession(currentActive.id) } let newSession = ClimbSession(gymId: gymId, notes: notes) activeSession = newSession sessions.append(newSession) saveActiveSession() saveSessions() DataStateManager.shared.updateDataState() // MARK: - Start Live Activity for new session if let gym = gym(withId: gymId) { Task { await LiveActivityManager.shared.startLiveActivity( for: newSession, gymName: gym.name) } } } func endSession(_ sessionId: UUID) { if let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), let index = sessions.firstIndex(where: { $0.id == sessionId }) { let completedSession = session.completed() sessions[index] = completedSession if activeSession?.id == sessionId { activeSession = nil } saveActiveSession() saveSessions() DataStateManager.shared.updateDataState() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) // MARK: - End Live Activity after session ends Task { await LiveActivityManager.shared.endLiveActivity() } } } func updateSession(_ session: ClimbSession) { if let index = sessions.firstIndex(where: { $0.id == session.id }) { sessions[index] = session if activeSession?.id == session.id { activeSession = session saveActiveSession() } saveSessions() DataStateManager.shared.updateDataState() // Update Live Activity when session is updated updateLiveActivityForActiveSession() // Only trigger sync if session is completed if session.status != .active { syncService.triggerAutoSync(dataManager: self) } } } func deleteSession(_ session: ClimbSession) { // Delete associated attempts first attempts.removeAll { $0.sessionId == session.id } saveAttempts() // If this is the active session, clear it if activeSession?.id == session.id { activeSession = nil saveActiveSession() } // Delete the session sessions.removeAll { $0.id == session.id } saveSessions() DataStateManager.shared.updateDataState() // Update Live Activity when session is deleted updateLiveActivityForActiveSession() // Trigger auto-sync if enabled syncService.triggerAutoSync(dataManager: self) } func session(withId id: UUID) -> ClimbSession? { return sessions.first { $0.id == id } } func sessions(forGym gymId: UUID) -> [ClimbSession] { return sessions.filter { $0.gymId == gymId } } func getLastUsedGym() -> Gym? { let recentSessions = sessions.sorted { $0.date > $1.date } guard let lastSession = recentSessions.first else { return nil } return gym(withId: lastSession.gymId) } func addAttempt(_ attempt: Attempt) { attempts.append(attempt) saveAttempts() DataStateManager.shared.updateDataState() // Update Live Activity when new attempt is added updateLiveActivityForActiveSession() } func updateAttempt(_ attempt: Attempt) { if let index = attempts.firstIndex(where: { $0.id == attempt.id }) { attempts[index] = attempt saveAttempts() DataStateManager.shared.updateDataState() // Update Live Activity when attempt is updated updateLiveActivityForActiveSession() } } func deleteAttempt(_ attempt: Attempt) { attempts.removeAll { $0.id == attempt.id } saveAttempts() DataStateManager.shared.updateDataState() // Update Live Activity when attempt is deleted updateLiveActivityForActiveSession() } func attempts(forSession sessionId: UUID) -> [Attempt] { return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp } } func attempts(forProblem problemId: UUID) -> [Attempt] { return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp } } func successfulAttempts(forProblem problemId: UUID) -> [Attempt] { return attempts.filter { $0.problemId == problemId && $0.result.isSuccessful } } func completedSessions() -> [ClimbSession] { return sessions.filter { $0.status == .completed } } func totalAttempts() -> Int { return attempts.count } func successfulAttempts() -> Int { return attempts.filter { $0.result.isSuccessful }.count } func completedProblems() -> Int { let completedProblemIds = Set( attempts.filter { $0.result.isSuccessful }.map { $0.problemId }) return completedProblemIds.count } func favoriteGym() -> Gym? { let gymSessionCounts = Dictionary(grouping: sessions, by: { $0.gymId }) .mapValues { $0.count } guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key else { return nil } return gym(withId: mostUsedGymId) } func resetAllData(showSuccessMessage: Bool = true) { gyms.removeAll() problems.removeAll() sessions.removeAll() attempts.removeAll() activeSession = nil userDefaults.removeObject(forKey: Keys.gyms) userDefaults.removeObject(forKey: Keys.problems) userDefaults.removeObject(forKey: Keys.sessions) userDefaults.removeObject(forKey: Keys.attempts) userDefaults.removeObject(forKey: Keys.activeSession) DataStateManager.shared.reset() if showSuccessMessage { successMessage = "All data has been reset" clearMessageAfterDelay() } } func exportData() -> Data? { do { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" let exportData = ClimbDataBackup( exportedAt: dateFormatter.string(from: Date()), version: "2.0", formatVersion: "2.0", gyms: gyms.map { BackupGym(from: $0) }, problems: problems.map { BackupProblem(from: $0) }, sessions: sessions.map { BackupClimbSession(from: $0) }, attempts: attempts.map { BackupAttempt(from: $0) } ) // Collect referenced image paths let referencedImagePaths = collectReferencedImagePaths() print("Starting export with \(referencedImagePaths.count) images") let zipData = try ZipUtils.createExportZip( exportData: exportData, referencedImagePaths: referencedImagePaths ) print("Export completed successfully") successMessage = "Export completed with \(referencedImagePaths.count) images" clearMessageAfterDelay() return zipData } catch { let errorMessage = "Export failed: \(error.localizedDescription)" print("ERROR: \(errorMessage)") setError(errorMessage) return nil } } func importData(from data: Data, showSuccessMessage: Bool = true) throws { do { let importResult = try ZipUtils.extractImportZip(data: data) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) if let date = ISO8601DateFormatter().date(from: dateString) { return date } if let date = dateFormatter.date(from: dateString) { return date } return Date() } print("Raw JSON content preview:") print(String(decoding: importResult.jsonData.prefix(500), as: UTF8.self) + "...") let importData = try decoder.decode(ClimbDataBackup.self, from: importResult.jsonData) print("Successfully decoded import data:") print("- Gyms: \(importData.gyms.count)") print("- Problems: \(importData.problems.count)") print("- Sessions: \(importData.sessions.count)") print("- Attempts: \(importData.attempts.count)") try validateImportData(importData) resetAllData(showSuccessMessage: showSuccessMessage) let updatedProblems = updateProblemImagePaths( problems: importData.problems, imagePathMapping: importResult.imagePathMapping ) self.gyms = try importData.gyms.map { try $0.toGym() } self.problems = try updatedProblems.map { try $0.toProblem() } self.sessions = try importData.sessions.map { try $0.toClimbSession() } self.attempts = try importData.attempts.map { try $0.toAttempt() } saveGyms() saveProblems() saveSessions() saveAttempts() // Update data state to current time since we just imported new data DataStateManager.shared.updateDataState() if showSuccessMessage { successMessage = "Data imported successfully with \(importResult.imagePathMapping.count) images" clearMessageAfterDelay() } } catch { setError("Import failed: \(error.localizedDescription)") throw error } } func clearMessages() { errorMessage = nil successMessage = nil } private func clearMessageAfterDelay() { Task { try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds successMessage = nil errorMessage = nil } } func setError(_ message: String) { errorMessage = message clearMessageAfterDelay() } } extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() print("Starting image path collection...") print("Total problems: \(problems.count)") for problem in problems { if !problem.imagePaths.isEmpty { print( "Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images" ) for imagePath in problem.imagePaths { print(" - Relative path: \(imagePath)") let fullPath = ImageManager.shared.getFullPath(from: imagePath) print(" - Full path: \(fullPath)") // Check if file exists if FileManager.default.fileExists(atPath: fullPath) { print(" File exists") imagePaths.insert(fullPath) } else { print(" File does NOT exist") // Still add it to let ZipUtils handle the error logging imagePaths.insert(fullPath) } } } } print("Collected \(imagePaths.count) total image paths for export") return imagePaths } private func updateProblemImagePaths( problems: [BackupProblem], imagePathMapping: [String: String] ) -> [BackupProblem] { return problems.map { problem in let updatedImagePaths = (problem.imagePaths ?? []).compactMap { oldPath in let fileName = URL(fileURLWithPath: oldPath).lastPathComponent return imagePathMapping[fileName] } return problem.withUpdatedImagePaths(updatedImagePaths) } } private func migrateImagePaths() { var needsUpdate = false let updatedProblems = problems.map { problem in let migratedPaths = problem.imagePaths.compactMap { path in // If it's already a relative path, keep it if !path.hasPrefix("/") { return path } // For absolute paths, try to migrate to relative let fileName = URL(fileURLWithPath: path).lastPathComponent if ImageManager.shared.imageExists(atPath: fileName) { needsUpdate = true return fileName } // If image doesn't exist, remove from paths needsUpdate = true return nil } if migratedPaths != problem.imagePaths { return problem.updated(imagePaths: migratedPaths) } return problem } if needsUpdate { problems = updatedProblems saveProblems() print("Migrated image paths for \(problems.count) problems") } } private func performImageMaintenance() async { // Run maintenance in background await Task.detached { await ImageManager.shared.performMaintenance() // Log storage information for debugging let info = await ImageManager.shared.getStorageInfo() print( "Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total" ) }.value } func manualImageMaintenance() { Task { await performImageMaintenance() } } func getImageStorageInfo() -> String { let info = ImageManager.shared.getStorageInfo() return """ Image Storage Status: • Primary: \(info.primaryCount) files • Backup: \(info.backupCount) files • Total Size: \(formatBytes(info.totalSize)) """ } func cleanupUnusedImages() { // Get all image paths currently referenced in problems let referencedImages = Set( problems.flatMap { $0.imagePaths.map { ImageManager.shared.getRelativePath(from: $0) } } ) // Get all files in storage if let primaryFiles = try? FileManager.default.contentsOfDirectory( atPath: ImageManager.shared.getImagesDirectoryPath()) { let orphanedFiles = primaryFiles.filter { !referencedImages.contains($0) } for fileName in orphanedFiles { _ = ImageManager.shared.deleteImage(atPath: fileName) } if !orphanedFiles.isEmpty { print("Cleaned up \(orphanedFiles.count) orphaned image files") } } } private func formatBytes(_ bytes: Int64) -> String { let kb = Double(bytes) / 1024.0 let mb = kb / 1024.0 if mb >= 1.0 { return String(format: "%.1f MB", mb) } else { return String(format: "%.0f KB", kb) } } func forceImageRecovery() { print("User initiated force image recovery") ImageManager.shared.forceRecoveryMigration() // Refresh the UI after recovery objectWillChange.send() } func emergencyImageRestore() { print("User initiated emergency image restore") ImageManager.shared.emergencyImageRestore() // Refresh the UI after restore objectWillChange.send() } func validateImageStorage() -> Bool { return ImageManager.shared.validateStorageIntegrity() } func getImageRecoveryStatus() -> String { let isValid = validateImageStorage() let info = ImageManager.shared.getStorageInfo() return """ Image Storage Health: \(isValid ? "Good" : "Needs Recovery") Primary Files: \(info.primaryCount) Backup Files: \(info.backupCount) Total Size: \(formatBytes(info.totalSize)) \(isValid ? "No action needed" : "Consider running Force Recovery") """ } func testLiveActivity() { print("🧪 Testing Live Activity functionality...") // Check Live Activity availability let status = LiveActivityManager.shared.checkLiveActivityAvailability() print(status) // Test with dummy data if we have a gym guard let testGym = gyms.first else { print("ERROR: No gyms available for testing") return } // Create a test session let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity") Task { await LiveActivityManager.shared.startLiveActivity( for: testSession, gymName: testGym.name) // Wait a bit then update try? await Task.sleep(nanoseconds: 2_000_000_000) await LiveActivityManager.shared.updateLiveActivity( elapsed: 120, totalAttempts: 5, completedProblems: 1) // Wait then end try? await Task.sleep(nanoseconds: 5_000_000_000) await LiveActivityManager.shared.endLiveActivity() } } private func checkAndRestartLiveActivity() async { guard let activeSession = activeSession else { // No active session, make sure all Live Activities are cleaned up await LiveActivityManager.shared.endLiveActivity() return } // Only restart if session is actually active guard activeSession.status == .active else { print( "WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity" ) await LiveActivityManager.shared.endLiveActivity() return } if let gym = gym(withId: activeSession.gymId) { print("Checking Live Activity for active session at \(gym.name)") // First cleanup any dismissed activities await LiveActivityManager.shared.cleanupDismissedActivities() // Then attempt to restart if needed await LiveActivityManager.shared.restartLiveActivityIfNeeded( activeSession: activeSession, gymName: gym.name ) } } /// Call this when app becomes active to check for Live Activity restart func onAppBecomeActive() { print("App became active - checking Live Activity status") Task { await checkAndRestartLiveActivity() } } /// Call this when app enters background to update Live Activity func onAppEnterBackground() { print("App entering background - updating Live Activity if needed") Task { await updateLiveActivityData() } } /// Setup notifications for Live Activity events private func setupLiveActivityNotifications() { liveActivityObserver = NotificationCenter.default.addObserver( forName: .liveActivityDismissed, object: nil, queue: .main ) { [weak self] _ in print("🔔 Received Live Activity dismissed notification - attempting restart") Task { @MainActor in await self?.handleLiveActivityDismissed() } } } /// Handle Live Activity being dismissed by user private func handleLiveActivityDismissed() async { guard let activeSession = activeSession, activeSession.status == .active, let gym = gym(withId: activeSession.gymId) else { return } print("Attempting to restart dismissed Live Activity for \(gym.name)") // Wait a bit before restarting to avoid frequency limits try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds await LiveActivityManager.shared.startLiveActivity( for: activeSession, gymName: gym.name ) // Update with current data await updateLiveActivityData() } /// Update Live Activity with current session statistics private func updateLiveActivityData() async { guard let activeSession = activeSession, activeSession.status == .active else { return } let elapsed = Date().timeIntervalSince(activeSession.startTime ?? activeSession.date) let sessionAttempts = attempts.filter { $0.sessionId == activeSession.id } let totalAttempts = sessionAttempts.count let completedProblems = Set( sessionAttempts.filter { $0.result.isSuccessful }.map { $0.problemId } ).count await LiveActivityManager.shared.updateLiveActivity( elapsed: elapsed, totalAttempts: totalAttempts, completedProblems: completedProblems ) } /// Update Live Activity with current session data private func updateLiveActivityForActiveSession() { guard let activeSession = activeSession, activeSession.status == .active, let gym = gym(withId: activeSession.gymId) else { print("WARNING: Live Activity update skipped - no active session or gym") if let session = activeSession { print(" Session ID: \(session.id)") print(" Session Status: \(session.status)") print(" Gym ID: \(session.gymId)") } return } let attemptsForSession = attempts(forSession: activeSession.id) let totalAttempts = attemptsForSession.count let completedProblemIds = Set( attemptsForSession.filter { $0.result.isSuccessful }.map { $0.problemId } ) let completedProblems = completedProblemIds.count let elapsedInterval: TimeInterval if let startTime = activeSession.startTime { elapsedInterval = Date().timeIntervalSince(startTime) } else { elapsedInterval = 0 } print("Live Activity Update Debug:") print(" Session ID: \(activeSession.id)") print(" Gym: \(gym.name)") print(" Total attempts in session: \(totalAttempts)") print(" Completed problems: \(completedProblems)") print(" Elapsed time: \(elapsedInterval) seconds") print( " All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })" ) Task { await LiveActivityManager.shared.updateLiveActivity( elapsed: elapsedInterval, totalAttempts: totalAttempts, completedProblems: completedProblems ) } } /// Manually force Live Activity update (useful for debugging) func forceLiveActivityUpdate() { updateLiveActivityForActiveSession() } /// Update widget timeline when data changes private func updateWidgetTimeline() { #if canImport(WidgetKit) WidgetCenter.shared.reloadTimelines(ofKind: "SessionStatusLive") #endif } /// Debug function to manually trigger widget data update func debugUpdateWidgetData() { // Force save all data to widget saveGyms() saveSessions() saveAttempts() } private func validateImportData(_ importData: ClimbDataBackup) throws { if importData.gyms.isEmpty { throw NSError( domain: "ImportError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Import data is invalid: no gyms found"]) } } } extension ClimbingDataManager { static var preview: ClimbingDataManager { let manager = ClimbingDataManager() let sampleGym = Gym( name: "Sample Climbing Gym", location: "123 Rock St, Boulder, CO", supportedClimbTypes: [.boulder, .rope], difficultySystems: [.vScale, .yds] ) manager.gyms = [sampleGym] let sampleProblem = Problem( gymId: sampleGym.id, name: "Crimpy Overhang", description: "Technical overhang with small holds", climbType: .boulder, difficulty: DifficultyGrade(system: .vScale, grade: "V4"), tags: ["technical", "overhang"], location: "Cave area" ) manager.problems = [sampleProblem] return manager } }