import Combine import Foundation import SwiftUI import UniformTypeIdentifiers #if canImport(WidgetKit) import WidgetKit #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 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() 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() } } 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) } } private 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) } } } private 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() } } private 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() successMessage = "Gym added successfully" clearMessageAfterDelay() } func updateGym(_ gym: Gym) { if let index = gyms.firstIndex(where: { $0.id == gym.id }) { gyms[index] = gym saveGyms() successMessage = "Gym updated successfully" clearMessageAfterDelay() } } 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() successMessage = "Gym deleted successfully" clearMessageAfterDelay() } func gym(withId id: UUID) -> Gym? { return gyms.first { $0.id == id } } func addProblem(_ problem: Problem) { problems.append(problem) saveProblems() successMessage = "Problem added successfully" clearMessageAfterDelay() } func updateProblem(_ problem: Problem) { if let index = problems.firstIndex(where: { $0.id == problem.id }) { problems[index] = problem saveProblems() successMessage = "Problem updated successfully" clearMessageAfterDelay() } } 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() } 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) { if let currentActive = activeSession { endSession(currentActive.id) } let newSession = ClimbSession(gymId: gymId, notes: notes) activeSession = newSession sessions.append(newSession) saveActiveSession() saveSessions() successMessage = "Session started successfully" clearMessageAfterDelay() // 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() successMessage = "Session completed successfully" clearMessageAfterDelay() // 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() successMessage = "Session updated successfully" clearMessageAfterDelay() // Update Live Activity when session updates updateLiveActivityForActiveSession() } } func deleteSession(_ session: ClimbSession) { // Delete associated attempts first attempts.removeAll { $0.sessionId == session.id } saveAttempts() // Remove from active session if it's the current one if activeSession?.id == session.id { activeSession = nil saveActiveSession() } // Delete the session sessions.removeAll { $0.id == session.id } saveSessions() successMessage = "Session deleted successfully" clearMessageAfterDelay() } 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() successMessage = "Attempt logged successfully" clearMessageAfterDelay() // 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() successMessage = "Attempt updated successfully" clearMessageAfterDelay() // Update Live Activity when attempt is updated updateLiveActivityForActiveSession() } } func deleteAttempt(_ attempt: Attempt) { attempts.removeAll { $0.id == attempt.id } saveAttempts() successMessage = "Attempt deleted successfully" clearMessageAfterDelay() // 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() { 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) 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 = ClimbDataExport( exportedAt: dateFormatter.string(from: Date()), gyms: gyms.map { AndroidGym(from: $0) }, problems: problems.map { AndroidProblem(from: $0) }, sessions: sessions.map { AndroidClimbSession(from: $0) }, attempts: attempts.map { AndroidAttempt(from: $0) } ) // Collect referenced image paths let referencedImagePaths = collectReferencedImagePaths() return try ZipUtils.createExportZip( exportData: exportData, referencedImagePaths: referencedImagePaths ) } catch { setError("Export failed: \(error.localizedDescription)") return nil } } func importData(from data: Data) 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(ClimbDataExport.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() let updatedProblems = updateProblemImagePaths( problems: importData.problems, imagePathMapping: importResult.imagePathMapping ) self.gyms = importData.gyms.map { $0.toGym() } self.problems = updatedProblems.map { $0.toProblem() } self.sessions = importData.sessions.map { $0.toClimbSession() } self.attempts = importData.attempts.map { $0.toAttempt() } saveGyms() saveProblems() saveSessions() saveAttempts() 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() } } struct ClimbDataExport: Codable { let exportedAt: String let gyms: [AndroidGym] let problems: [AndroidProblem] let sessions: [AndroidClimbSession] let attempts: [AndroidAttempt] init( exportedAt: String, gyms: [AndroidGym], problems: [AndroidProblem], sessions: [AndroidClimbSession], attempts: [AndroidAttempt] ) { self.exportedAt = exportedAt self.gyms = gyms self.problems = problems self.sessions = sessions self.attempts = attempts } } struct AndroidGym: Codable { let id: String let name: String let location: String? let supportedClimbTypes: [ClimbType] let difficultySystems: [DifficultySystem] let notes: String? let createdAt: String let updatedAt: String init(from gym: Gym) { self.id = gym.id.uuidString self.name = gym.name self.location = gym.location self.supportedClimbTypes = gym.supportedClimbTypes self.difficultySystems = gym.difficultySystems self.notes = gym.notes let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" self.createdAt = formatter.string(from: gym.createdAt) self.updatedAt = formatter.string(from: gym.updatedAt) } init( id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], difficultySystems: [DifficultySystem], notes: String?, createdAt: String, updatedAt: String ) { self.id = id self.name = name self.location = location self.supportedClimbTypes = supportedClimbTypes self.difficultySystems = difficultySystems self.notes = notes self.createdAt = createdAt self.updatedAt = updatedAt } func toGym() -> Gym { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" let gymId = UUID(uuidString: id) ?? UUID() let createdDate = formatter.date(from: createdAt) ?? Date() let updatedDate = formatter.date(from: updatedAt) ?? Date() return Gym.fromImport( id: gymId, name: name, location: location, supportedClimbTypes: supportedClimbTypes, difficultySystems: difficultySystems, customDifficultyGrades: [], notes: notes, createdAt: createdDate, updatedAt: updatedDate ) } } struct AndroidProblem: Codable { let id: String let gymId: String let name: String? let description: String? let climbType: ClimbType let difficulty: DifficultyGrade let imagePaths: [String]? let createdAt: String let updatedAt: String init(from problem: Problem) { self.id = problem.id.uuidString self.gymId = problem.gymId.uuidString self.name = problem.name self.description = problem.description self.climbType = problem.climbType self.difficulty = problem.difficulty self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" self.createdAt = formatter.string(from: problem.createdAt) self.updatedAt = formatter.string(from: problem.updatedAt) } init( id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, difficulty: DifficultyGrade, imagePaths: [String]?, createdAt: String, updatedAt: String ) { self.id = id self.gymId = gymId self.name = name self.description = description self.climbType = climbType self.difficulty = difficulty self.imagePaths = imagePaths self.createdAt = createdAt self.updatedAt = updatedAt } func toProblem() -> Problem { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" let problemId = UUID(uuidString: id) ?? UUID() let preservedGymId = UUID(uuidString: gymId) ?? UUID() let createdDate = formatter.date(from: createdAt) ?? Date() let updatedDate = formatter.date(from: updatedAt) ?? Date() return Problem.fromImport( id: problemId, gymId: preservedGymId, name: name, description: description, climbType: climbType, difficulty: difficulty, setter: nil, tags: [], location: nil, imagePaths: imagePaths ?? [], isActive: true, dateSet: nil, notes: nil, createdAt: createdDate, updatedAt: updatedDate ) } func withUpdatedImagePaths(_ newImagePaths: [String]) -> AndroidProblem { return AndroidProblem( id: self.id, gymId: self.gymId, name: self.name, description: self.description, climbType: self.climbType, difficulty: self.difficulty, imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, createdAt: self.createdAt, updatedAt: self.updatedAt ) } } struct AndroidClimbSession: Codable { let id: String let gymId: String let date: String let startTime: String? let endTime: String? let duration: Int? let status: SessionStatus let createdAt: String let updatedAt: String init(from session: ClimbSession) { self.id = session.id.uuidString self.gymId = session.gymId.uuidString let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" self.date = formatter.string(from: session.date) self.startTime = session.startTime != nil ? formatter.string(from: session.startTime!) : nil self.endTime = session.endTime != nil ? formatter.string(from: session.endTime!) : nil self.duration = session.duration self.status = session.status self.createdAt = formatter.string(from: session.createdAt) self.updatedAt = formatter.string(from: session.updatedAt) } init( id: String, gymId: String, date: String, startTime: String?, endTime: String?, duration: Int?, status: SessionStatus, createdAt: String, updatedAt: String ) { self.id = id self.gymId = gymId self.date = date self.startTime = startTime self.endTime = endTime self.duration = duration self.status = status self.createdAt = createdAt self.updatedAt = updatedAt } func toClimbSession() -> ClimbSession { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" // Preserve original IDs and dates let sessionId = UUID(uuidString: id) ?? UUID() let preservedGymId = UUID(uuidString: gymId) ?? UUID() let sessionDate = formatter.date(from: date) ?? Date() let sessionStartTime = startTime != nil ? formatter.date(from: startTime!) : nil let sessionEndTime = endTime != nil ? formatter.date(from: endTime!) : nil let createdDate = formatter.date(from: createdAt) ?? Date() let updatedDate = formatter.date(from: updatedAt) ?? Date() return ClimbSession.fromImport( id: sessionId, gymId: preservedGymId, date: sessionDate, startTime: sessionStartTime, endTime: sessionEndTime, duration: duration, status: status, notes: nil, createdAt: createdDate, updatedAt: updatedDate ) } } struct AndroidAttempt: Codable { let id: String let sessionId: String let problemId: String let result: AttemptResult let highestHold: String? let notes: String? let duration: Int? let restTime: Int? let timestamp: String let createdAt: String init(from attempt: Attempt) { self.id = attempt.id.uuidString self.sessionId = attempt.sessionId.uuidString self.problemId = attempt.problemId.uuidString self.result = attempt.result self.highestHold = attempt.highestHold self.notes = attempt.notes self.duration = attempt.duration self.restTime = attempt.restTime let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" self.timestamp = formatter.string(from: attempt.timestamp) self.createdAt = formatter.string(from: attempt.createdAt) } init( id: String, sessionId: String, problemId: String, result: AttemptResult, highestHold: String?, notes: String?, duration: Int?, restTime: Int?, timestamp: String, createdAt: String ) { self.id = id self.sessionId = sessionId self.problemId = problemId self.result = result self.highestHold = highestHold self.notes = notes self.duration = duration self.restTime = restTime self.timestamp = timestamp self.createdAt = createdAt } func toAttempt() -> Attempt { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" let attemptId = UUID(uuidString: id) ?? UUID() let preservedSessionId = UUID(uuidString: sessionId) ?? UUID() let preservedProblemId = UUID(uuidString: problemId) ?? UUID() let attemptTimestamp = formatter.date(from: timestamp) ?? Date() let createdDate = formatter.date(from: createdAt) ?? Date() return Attempt.fromImport( id: attemptId, sessionId: preservedSessionId, problemId: preservedProblemId, result: result, highestHold: highestHold, notes: notes, duration: duration, restTime: restTime, timestamp: attemptTimestamp, createdAt: createdDate ) } } extension ClimbingDataManager { private func collectReferencedImagePaths() -> Set { var imagePaths = Set() for problem in problems { imagePaths.formUnion(problem.imagePaths) } return imagePaths } private func updateProblemImagePaths( problems: [AndroidProblem], imagePathMapping: [String: String] ) -> [AndroidProblem] { 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("โŒ 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 { return } if let gym = gym(withId: activeSession.gymId) { await LiveActivityManager.shared.restartLiveActivityIfNeeded( activeSession: activeSession, gymName: gym.name ) } } /// Call this when app becomes active to check for Live Activity restart func onAppBecomeActive() { Task { await checkAndRestartLiveActivity() } } /// 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("โš ๏ธ 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: ClimbDataExport) 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"), setter: "John Doe", tags: ["technical", "overhang"], location: "Cave area" ) manager.problems = [sampleProblem] return manager } }