1436 lines
48 KiB
Swift
1436 lines
48 KiB
Swift
import Combine
|
|
import Foundation
|
|
import HealthKit
|
|
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.Ascently")
|
|
private let encoder = JSONEncoder()
|
|
private let decoder = JSONDecoder()
|
|
|
|
// Flag to track if migration has been performed
|
|
private let migrationKey = "ascently_data_migrated_from_openclimb"
|
|
nonisolated(unsafe) private var liveActivityObserver: NSObjectProtocol?
|
|
nonisolated(unsafe) private var migrationObserver: NSObjectProtocol?
|
|
|
|
let syncService = SyncService()
|
|
let healthKitService = HealthKitService.shared
|
|
|
|
@Published var isSyncing = false
|
|
|
|
private enum Keys {
|
|
static let gyms = "ascently_gyms"
|
|
static let problems = "ascently_problems"
|
|
static let sessions = "ascently_sessions"
|
|
static let attempts = "ascently_attempts"
|
|
static let activeSession = "ascently_active_session"
|
|
static let deletedItems = "ascently_deleted_items"
|
|
}
|
|
|
|
// Legacy keys for migration
|
|
private enum LegacyKeys {
|
|
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"
|
|
static let deletedItems = "openclimb_deleted_items"
|
|
}
|
|
|
|
// 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
|
|
migrateFromOpenClimbIfNeeded()
|
|
loadAllData()
|
|
setupLiveActivityNotifications()
|
|
setupMigrationNotifications()
|
|
|
|
// 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)
|
|
}
|
|
if let observer = migrationObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
}
|
|
}
|
|
|
|
/// Migrate data from OpenClimb keys to Ascently keys
|
|
private func migrateFromOpenClimbIfNeeded() {
|
|
// Check if migration has already been performed
|
|
if userDefaults.bool(forKey: migrationKey) {
|
|
return
|
|
}
|
|
|
|
print("Starting migration from OpenClimb to Ascently keys...")
|
|
var migrationCount = 0
|
|
|
|
// Migrate each data type if it exists in old format but not in new format
|
|
let migrations = [
|
|
(LegacyKeys.gyms, Keys.gyms),
|
|
(LegacyKeys.problems, Keys.problems),
|
|
(LegacyKeys.sessions, Keys.sessions),
|
|
(LegacyKeys.attempts, Keys.attempts),
|
|
(LegacyKeys.activeSession, Keys.activeSession),
|
|
(LegacyKeys.deletedItems, Keys.deletedItems),
|
|
]
|
|
|
|
for (oldKey, newKey) in migrations {
|
|
if let oldData = userDefaults.data(forKey: oldKey),
|
|
userDefaults.data(forKey: newKey) == nil
|
|
{
|
|
userDefaults.set(oldData, forKey: newKey)
|
|
userDefaults.removeObject(forKey: oldKey)
|
|
migrationCount += 1
|
|
print("✅ Migrated: \(oldKey) → \(newKey)")
|
|
}
|
|
}
|
|
|
|
// Also migrate shared UserDefaults for widgets
|
|
if let sharedDefaults = sharedUserDefaults {
|
|
for (oldKey, newKey) in migrations {
|
|
if let oldData = sharedDefaults.data(forKey: oldKey),
|
|
sharedDefaults.data(forKey: newKey) == nil
|
|
{
|
|
sharedDefaults.set(oldData, forKey: newKey)
|
|
sharedDefaults.removeObject(forKey: oldKey)
|
|
print("✅ Migrated shared: \(oldKey) → \(newKey)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Migrate DataStateManager keys
|
|
let legacyDataStateKey = "openclimb_data_last_modified"
|
|
let newDataStateKey = "ascently_data_last_modified"
|
|
if let lastModified = userDefaults.string(forKey: legacyDataStateKey),
|
|
userDefaults.string(forKey: newDataStateKey) == nil
|
|
{
|
|
userDefaults.set(lastModified, forKey: newDataStateKey)
|
|
userDefaults.removeObject(forKey: legacyDataStateKey)
|
|
migrationCount += 1
|
|
print("✅ Migrated data state timestamp")
|
|
}
|
|
|
|
// Mark migration as completed
|
|
userDefaults.set(true, forKey: migrationKey)
|
|
|
|
if migrationCount > 0 {
|
|
print(
|
|
"Migration completed! Migrated \(migrationCount) data items from OpenClimb to Ascently"
|
|
)
|
|
} else {
|
|
print("No OpenClimb data found to migrate")
|
|
}
|
|
}
|
|
|
|
private func loadAllData() {
|
|
loadGyms()
|
|
loadProblems()
|
|
loadSessions()
|
|
loadAttempts()
|
|
loadActiveSession()
|
|
|
|
// Clean up orphaned data after loading
|
|
cleanupOrphanedData()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
internal 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
internal 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 }
|
|
trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
|
|
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) {
|
|
// Track deletion of the problem
|
|
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
|
|
|
|
// Find and track all attempts for this problem as deleted
|
|
let problemAttempts = attempts.filter { $0.problemId == problem.id }
|
|
for attempt in problemAttempts {
|
|
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
|
}
|
|
|
|
// Delete associated attempts
|
|
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)
|
|
}
|
|
}
|
|
|
|
if healthKitService.isEnabled {
|
|
Task {
|
|
do {
|
|
try await healthKitService.startWorkout(
|
|
startDate: newSession.startTime ?? Date(),
|
|
sessionId: newSession.id)
|
|
} catch {
|
|
print("Failed to start HealthKit workout: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
if healthKitService.isEnabled {
|
|
Task {
|
|
do {
|
|
try await healthKitService.endWorkout(
|
|
endDate: completedSession.endTime ?? Date())
|
|
} catch {
|
|
print("Failed to end HealthKit workout: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Track deletion of the session
|
|
trackDeletion(itemId: session.id.uuidString, itemType: "session")
|
|
|
|
// Find and track all attempts for this session as deleted
|
|
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
|
|
for attempt in sessionAttempts {
|
|
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
|
}
|
|
|
|
// Delete associated attempts
|
|
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 }
|
|
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
|
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 }
|
|
}
|
|
|
|
// MARK: - Deletion Tracking
|
|
|
|
private func trackDeletion(itemId: String, itemType: String) {
|
|
let deletion = DeletedItem(
|
|
id: itemId,
|
|
type: itemType,
|
|
deletedAt: ISO8601DateFormatter().string(from: Date())
|
|
)
|
|
|
|
var currentDeletions = getDeletedItems()
|
|
currentDeletions.append(deletion)
|
|
|
|
if let data = try? encoder.encode(currentDeletions) {
|
|
userDefaults.set(data, forKey: Keys.deletedItems)
|
|
}
|
|
}
|
|
|
|
func getDeletedItems() -> [DeletedItem] {
|
|
guard let data = userDefaults.data(forKey: Keys.deletedItems),
|
|
let deletions = try? decoder.decode([DeletedItem].self, from: data)
|
|
else {
|
|
return []
|
|
}
|
|
return deletions
|
|
}
|
|
|
|
func clearDeletedItems() {
|
|
userDefaults.removeObject(forKey: Keys.deletedItems)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private func cleanupOrphanedData() {
|
|
let validSessionIds = Set(sessions.map { $0.id })
|
|
let validProblemIds = Set(problems.map { $0.id })
|
|
let validGymIds = Set(gyms.map { $0.id })
|
|
|
|
let initialAttemptCount = attempts.count
|
|
|
|
// Remove attempts that reference deleted sessions or problems
|
|
let orphanedAttempts = attempts.filter { attempt in
|
|
!validSessionIds.contains(attempt.sessionId)
|
|
|| !validProblemIds.contains(attempt.problemId)
|
|
}
|
|
|
|
if !orphanedAttempts.isEmpty {
|
|
print("🧹 Cleaning up \(orphanedAttempts.count) orphaned attempts")
|
|
|
|
// Track these as deleted to prevent sync from re-introducing them
|
|
for attempt in orphanedAttempts {
|
|
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
|
}
|
|
|
|
// Remove orphaned attempts
|
|
attempts.removeAll { attempt in
|
|
!validSessionIds.contains(attempt.sessionId)
|
|
|| !validProblemIds.contains(attempt.problemId)
|
|
}
|
|
}
|
|
|
|
// Remove duplicate attempts (same session, problem, and timestamp within 1 second)
|
|
var seenAttempts: Set<String> = []
|
|
var duplicateIds: [UUID] = []
|
|
|
|
for attempt in attempts.sorted(by: { $0.timestamp < $1.timestamp }) {
|
|
// Create a unique key based on session, problem, and rounded timestamp
|
|
let timestampKey = Int(attempt.timestamp.timeIntervalSince1970)
|
|
let key =
|
|
"\(attempt.sessionId.uuidString)_\(attempt.problemId.uuidString)_\(timestampKey)"
|
|
|
|
if seenAttempts.contains(key) {
|
|
duplicateIds.append(attempt.id)
|
|
print("🧹 Found duplicate attempt: \(attempt.id)")
|
|
} else {
|
|
seenAttempts.insert(key)
|
|
}
|
|
}
|
|
|
|
if !duplicateIds.isEmpty {
|
|
print("🧹 Removing \(duplicateIds.count) duplicate attempts")
|
|
|
|
// Track duplicates as deleted
|
|
for attemptId in duplicateIds {
|
|
trackDeletion(itemId: attemptId.uuidString, itemType: "attempt")
|
|
}
|
|
|
|
// Remove duplicates
|
|
attempts.removeAll { duplicateIds.contains($0.id) }
|
|
}
|
|
|
|
if initialAttemptCount != attempts.count {
|
|
saveAttempts()
|
|
let removedCount = initialAttemptCount - attempts.count
|
|
print(
|
|
"Cleanup complete. Removed \(removedCount) attempts. Remaining: \(attempts.count)"
|
|
)
|
|
}
|
|
|
|
// Remove problems that reference deleted gyms
|
|
let orphanedProblems = problems.filter { problem in
|
|
!validGymIds.contains(problem.gymId)
|
|
}
|
|
|
|
if !orphanedProblems.isEmpty {
|
|
print("🧹 Cleaning up \(orphanedProblems.count) orphaned problems")
|
|
|
|
for problem in orphanedProblems {
|
|
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
|
|
}
|
|
|
|
problems.removeAll { problem in
|
|
!validGymIds.contains(problem.gymId)
|
|
}
|
|
|
|
saveProblems()
|
|
}
|
|
|
|
// Remove sessions that reference deleted gyms
|
|
let orphanedSessions = sessions.filter { session in
|
|
!validGymIds.contains(session.gymId)
|
|
}
|
|
|
|
if !orphanedSessions.isEmpty {
|
|
print("🧹 Cleaning up \(orphanedSessions.count) orphaned sessions")
|
|
|
|
for session in orphanedSessions {
|
|
trackDeletion(itemId: session.id.uuidString, itemType: "session")
|
|
}
|
|
|
|
sessions.removeAll { session in
|
|
!validGymIds.contains(session.gymId)
|
|
}
|
|
|
|
saveSessions()
|
|
}
|
|
}
|
|
|
|
func validateDataIntegrity() -> String {
|
|
let validSessionIds = Set(sessions.map { $0.id })
|
|
let validProblemIds = Set(problems.map { $0.id })
|
|
let validGymIds = Set(gyms.map { $0.id })
|
|
|
|
let orphanedAttempts = attempts.filter { attempt in
|
|
!validSessionIds.contains(attempt.sessionId)
|
|
|| !validProblemIds.contains(attempt.problemId)
|
|
}
|
|
|
|
let orphanedProblems = problems.filter { problem in
|
|
!validGymIds.contains(problem.gymId)
|
|
}
|
|
|
|
let orphanedSessions = sessions.filter { session in
|
|
!validGymIds.contains(session.gymId)
|
|
}
|
|
|
|
var report = "Data Integrity Report:\n"
|
|
report += "---------------------\n"
|
|
report += "Gyms: \(gyms.count)\n"
|
|
report += "Problems: \(problems.count)\n"
|
|
report += "Sessions: \(sessions.count)\n"
|
|
report += "Attempts: \(attempts.count)\n"
|
|
report += "\nOrphaned Data:\n"
|
|
report += "Orphaned Attempts: \(orphanedAttempts.count)\n"
|
|
report += "Orphaned Problems: \(orphanedProblems.count)\n"
|
|
report += "Orphaned Sessions: \(orphanedSessions.count)\n"
|
|
|
|
if orphanedAttempts.isEmpty && orphanedProblems.isEmpty && orphanedSessions.isEmpty {
|
|
report += "\nNo integrity issues found"
|
|
} else {
|
|
report += "\nIssues found - run cleanup to fix"
|
|
}
|
|
|
|
return report
|
|
}
|
|
|
|
func manualDataCleanup() {
|
|
cleanupOrphanedData()
|
|
successMessage = "Data cleanup completed"
|
|
clearMessageAfterDelay()
|
|
}
|
|
|
|
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() async -> Data? {
|
|
do {
|
|
// Create backup objects on main thread (they access MainActor-isolated properties)
|
|
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) }
|
|
)
|
|
|
|
// Get image manager path info on main thread
|
|
let imagesDirectory = ImageManager.shared.imagesDirectory.path
|
|
let problemsForImages = problems
|
|
|
|
// Move heavy I/O operations to background thread
|
|
let zipData = try await Task.detached(priority: .userInitiated) {
|
|
// Collect actual image paths from disk for the ZIP
|
|
let referencedImagePaths = await Self.collectReferencedImagePathsStatic(
|
|
problems: problemsForImages,
|
|
imagesDirectory: imagesDirectory)
|
|
print("Starting export with \(referencedImagePaths.count) images")
|
|
|
|
let zipData = try await ZipUtils.createExportZip(
|
|
exportData: exportData,
|
|
referencedImagePaths: referencedImagePaths
|
|
)
|
|
|
|
print("Export completed successfully")
|
|
return (zipData, referencedImagePaths.count)
|
|
}.value
|
|
|
|
successMessage = "Export completed with \(zipData.1) images"
|
|
clearMessageAfterDelay()
|
|
return zipData.0
|
|
} 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<String> {
|
|
let imagesDirectory = ImageManager.shared.imagesDirectory.path
|
|
return Self.collectReferencedImagePathsStatic(
|
|
problems: problems,
|
|
imagesDirectory: imagesDirectory)
|
|
}
|
|
|
|
private static func collectReferencedImagePathsStatic(
|
|
problems: [Problem], imagesDirectory: String
|
|
) -> Set<String> {
|
|
var imagePaths = Set<String>()
|
|
var missingCount = 0
|
|
|
|
for problem in problems {
|
|
if !problem.imagePaths.isEmpty {
|
|
for imagePath in problem.imagePaths {
|
|
// Extract just the filename (migration should have normalized these)
|
|
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
|
let fullPath = (imagesDirectory as NSString).appendingPathComponent(filename)
|
|
|
|
if FileManager.default.fileExists(atPath: fullPath) {
|
|
imagePaths.insert(fullPath)
|
|
} else {
|
|
missingCount += 1
|
|
imagePaths.insert(fullPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
print("Export: Collected \(imagePaths.count) images (\(missingCount) missing)")
|
|
return imagePaths
|
|
}
|
|
|
|
private func updateProblemImagePaths(
|
|
problems: [BackupProblem],
|
|
imagePathMapping: [String: String]
|
|
) -> [BackupProblem] {
|
|
return problems.map { problem in
|
|
guard let originalImagePaths = problem.imagePaths, !originalImagePaths.isEmpty else {
|
|
return problem
|
|
}
|
|
|
|
var deterministicImagePaths: [String] = []
|
|
|
|
for (index, oldPath) in originalImagePaths.enumerated() {
|
|
let originalFileName = URL(fileURLWithPath: oldPath).lastPathComponent
|
|
|
|
let deterministicName = ImageNamingUtils.generateImageFilename(
|
|
problemId: problem.id, imageIndex: index)
|
|
|
|
if let tempFileName = imagePathMapping[originalFileName] {
|
|
let imageManager = ImageManager.shared
|
|
let tempPath = imageManager.imagesDirectory.appendingPathComponent(tempFileName)
|
|
let deterministicPath = imageManager.imagesDirectory.appendingPathComponent(
|
|
deterministicName)
|
|
|
|
do {
|
|
if FileManager.default.fileExists(atPath: tempPath.path) {
|
|
try FileManager.default.moveItem(at: tempPath, to: deterministicPath)
|
|
|
|
let tempBackupPath = imageManager.backupDirectory
|
|
.appendingPathComponent(tempFileName)
|
|
let deterministicBackupPath = imageManager.backupDirectory
|
|
.appendingPathComponent(deterministicName)
|
|
|
|
if FileManager.default.fileExists(atPath: tempBackupPath.path) {
|
|
try? FileManager.default.moveItem(
|
|
at: tempBackupPath, to: deterministicBackupPath)
|
|
}
|
|
|
|
deterministicImagePaths.append(deterministicName)
|
|
print("Renamed imported image: \(tempFileName) → \(deterministicName)")
|
|
}
|
|
} catch {
|
|
print(
|
|
"Failed to rename imported image \(tempFileName) to \(deterministicName): \(error)"
|
|
)
|
|
deterministicImagePaths.append(tempFileName)
|
|
}
|
|
} else {
|
|
deterministicImagePaths.append(deterministicName)
|
|
}
|
|
}
|
|
|
|
return problem.withUpdatedImagePaths(deterministicImagePaths)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupMigrationNotifications() {
|
|
migrationObserver = NotificationCenter.default.addObserver(
|
|
forName: NSNotification.Name("ImageMigrationCompleted"),
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
if let updateCount = notification.userInfo?["updateCount"] as? Int {
|
|
print("🔔 Image migration completed with \(updateCount) updates - reloading data")
|
|
Task { @MainActor in
|
|
self?.loadProblems()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|