diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj
index 51b5031..0c86ee8 100644
--- a/ios/Ascently.xcodeproj/project.pbxproj
+++ b/ios/Ascently.xcodeproj/project.pbxproj
@@ -466,7 +466,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -491,7 +491,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
- MARKETING_VERSION = 2.6.1;
+ MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -518,7 +518,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -543,7 +543,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
- MARKETING_VERSION = 2.6.1;
+ MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -610,7 +610,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -622,7 +622,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.6.1;
+ MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -641,7 +641,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 44;
+ CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -653,7 +653,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.6.1;
+ MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate
index c926ae3..dcd40c2 100644
Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/ios/Ascently/Ascently.entitlements b/ios/Ascently/Ascently.entitlements
index b00abdd..226be1d 100644
--- a/ios/Ascently/Ascently.entitlements
+++ b/ios/Ascently/Ascently.entitlements
@@ -2,13 +2,25 @@
- com.apple.security.application-groups
-
- group.com.atridad.Ascently
-
+ aps-environment
+ development
com.apple.developer.healthkit
com.apple.developer.healthkit.access
+ com.apple.developer.icloud-container-identifiers
+
+ iCloud.com.atridad.Ascently
+
+ com.apple.developer.icloud-services
+
+ CloudKit
+
+ com.apple.developer.ubiquity-kvstore-identifier
+ $(TeamIdentifierPrefix)com.atridad.Ascently
+ com.apple.security.application-groups
+
+ group.com.atridad.Ascently
+
diff --git a/ios/Ascently/Services/Sync/ICloudSyncProvider.swift b/ios/Ascently/Services/Sync/ICloudSyncProvider.swift
new file mode 100644
index 0000000..288175d
--- /dev/null
+++ b/ios/Ascently/Services/Sync/ICloudSyncProvider.swift
@@ -0,0 +1,584 @@
+import CloudKit
+import Foundation
+import UIKit
+
+class ICloudSyncProvider: SyncProvider {
+ // MARK: - Properties
+
+ var type: SyncProviderType { .iCloud }
+
+ var isConfigured: Bool {
+ return true
+ }
+
+ var isConnected: Bool {
+ get { userDefaults.bool(forKey: Keys.isConnected) }
+ set { userDefaults.set(newValue, forKey: Keys.isConnected) }
+ }
+
+ var lastSyncTime: Date? {
+ return userDefaults.object(forKey: Keys.lastSyncTime) as? Date
+ }
+
+ private let container = CKContainer.default()
+ private lazy var database = container.privateCloudDatabase
+ private let zoneID = CKRecordZone.ID(zoneName: "AscentlyZone", ownerName: CKCurrentUserDefaultName)
+
+ private let userDefaults = UserDefaults.standard
+ private let logTag = "ICloudSync"
+
+ private enum Keys {
+ static let serverChangeToken = "Ascently.ICloud.ServerChangeToken"
+ static let lastSyncTime = "Ascently.ICloud.LastSyncTime"
+ static let zoneCreated = "Ascently.ICloud.ZoneCreated"
+ static let isConnected = "Ascently.ICloud.IsConnected"
+ }
+
+ // MARK: - Init
+
+ init() {
+ Task {
+ try? await checkAccountStatus()
+ }
+ }
+
+ // MARK: - SyncProvider Protocol
+
+ func testConnection() async throws {
+ try await checkAccountStatus()
+ }
+
+ func disconnect() {
+ isConnected = false
+ }
+
+ func sync(dataManager: ClimbingDataManager) async throws {
+ if !isConnected {
+ try await checkAccountStatus()
+ if !isConnected {
+ throw SyncError.notConnected
+ }
+ }
+
+ AppLogger.info("Starting iCloud sync", tag: logTag)
+
+ do {
+ try await createZoneIfNeeded()
+
+ do {
+ try await pullChanges(dataManager: dataManager)
+ } catch {
+ let errorString = String(describing: error)
+ if errorString.contains("recordName") && errorString.contains("queryable") {
+ AppLogger.warning("Schema initialization detected. Skipping pull to attempt self-healing via push.", tag: logTag)
+ } else {
+ throw error
+ }
+ }
+
+ try await pushChanges(dataManager: dataManager)
+
+ userDefaults.set(Date(), forKey: Keys.lastSyncTime)
+
+ AppLogger.info("iCloud sync completed successfully", tag: logTag)
+ } catch {
+ AppLogger.error("iCloud sync failed: \(error.localizedDescription)", tag: logTag)
+ throw error
+ }
+ }
+
+ // MARK: - Private Methods
+
+ private func checkAccountStatus() async throws {
+ let status: CKAccountStatus
+ do {
+ status = try await container.accountStatus()
+ } catch {
+ AppLogger.error("Failed to get iCloud account status: \(error). This often indicates a mismatch between the App Bundle ID and the iCloud Container configuration in the Provisioning Profile.", tag: logTag)
+ throw error
+ }
+
+ AppLogger.info("iCloud account status: \(status.rawValue)", tag: logTag)
+
+ switch status {
+ case .available:
+ isConnected = true
+ case .noAccount:
+ isConnected = false
+ throw SyncError.providerError("No iCloud account found. Please sign in to iCloud settings.")
+ case .restricted:
+ isConnected = false
+ throw SyncError.providerError("iCloud access is restricted.")
+ case .couldNotDetermine:
+ isConnected = false
+ throw SyncError.providerError("Could not determine iCloud account status.")
+ case .temporarilyUnavailable:
+ isConnected = false
+ throw SyncError.providerError("iCloud is temporarily unavailable.")
+ @unknown default:
+ isConnected = false
+ throw SyncError.providerError("Unknown iCloud error.")
+ }
+ }
+
+ private func createZoneIfNeeded() async throws {
+ if userDefaults.bool(forKey: Keys.zoneCreated) {
+ return
+ }
+
+ do {
+ let zone = CKRecordZone(zoneID: zoneID)
+ try await database.save(zone)
+ userDefaults.set(true, forKey: Keys.zoneCreated)
+ AppLogger.info("Created custom record zone: \(zoneID.zoneName)", tag: logTag)
+ } catch {
+ // If zone already exists, that's fine
+ if let ckError = error as? CKError {
+ if ckError.code == .serverRecordChanged {
+ userDefaults.set(true, forKey: Keys.zoneCreated)
+ return
+ }
+
+ if ckError.code == .permissionFailure {
+ AppLogger.error("CloudKit Permission Failure. This usually indicates 'Invalid Bundle ID'. Please Clean Build Folder and ensure Provisioning Profile matches entitlements.", tag: logTag)
+ }
+ }
+ throw error
+ }
+ }
+
+ // MARK: - Pull (Fetch from CloudKit)
+
+ private func pullChanges(dataManager: ClimbingDataManager) async throws {
+ var previousToken: CKServerChangeToken? = nil
+ if let tokenData = userDefaults.data(forKey: Keys.serverChangeToken) {
+ previousToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
+ }
+
+ var changedRecords: [CKRecord] = []
+ var deletedRecordIDs: [CKRecord.ID] = []
+
+ let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
+ config.previousServerChangeToken = previousToken
+
+ return try await withCheckedThrowingContinuation { continuation in
+ let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID], configurationsByRecordZoneID: [zoneID: config])
+
+ operation.recordWasChangedBlock = { recordID, result in
+ switch result {
+ case .success(let record):
+ changedRecords.append(record)
+ case .failure(let error):
+ AppLogger.error("Failed to fetch record \(recordID): \(error)", tag: self.logTag)
+ }
+ }
+
+ operation.recordWithIDWasDeletedBlock = { recordID, _ in
+ deletedRecordIDs.append(recordID)
+ }
+
+ operation.recordZoneChangeTokensUpdatedBlock = { zoneID, token, _ in
+ if let token = token {
+ self.saveChangeToken(token)
+ }
+ }
+
+ operation.fetchRecordZoneChangesResultBlock = { result in
+ switch result {
+ case .success:
+ Task {
+ await self.applyChanges(changedRecords: changedRecords, deletedRecordIDs: deletedRecordIDs, dataManager: dataManager)
+ continuation.resume()
+ }
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+
+ database.add(operation)
+ }
+ }
+
+ private func saveChangeToken(_ token: CKServerChangeToken) {
+ if let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) {
+ userDefaults.set(data, forKey: Keys.serverChangeToken)
+ }
+ }
+
+ private func applyChanges(changedRecords: [CKRecord], deletedRecordIDs: [CKRecord.ID], dataManager: ClimbingDataManager) async {
+ guard !changedRecords.isEmpty || !deletedRecordIDs.isEmpty else { return }
+
+ AppLogger.info("Applying CloudKit changes: \(changedRecords.count) updates, \(deletedRecordIDs.count) deletions", tag: logTag)
+
+ await MainActor.run {
+ for recordID in deletedRecordIDs {
+ let uuidString = recordID.recordName
+ if let uuid = UUID(uuidString: uuidString) {
+ if let gym = dataManager.gym(withId: uuid) { dataManager.deleteGym(gym) }
+ else if let problem = dataManager.problem(withId: uuid) { dataManager.deleteProblem(problem) }
+ else if let session = dataManager.session(withId: uuid) { dataManager.deleteSession(session) }
+ else if let attempt = dataManager.attempts.first(where: { $0.id == uuid }) { dataManager.deleteAttempt(attempt) }
+ }
+ }
+
+ for record in changedRecords {
+ guard let uuid = UUID(uuidString: record.recordID.recordName) else { continue }
+
+ switch record.recordType {
+ case "Gym":
+ if let gym = mapRecordToGym(record, id: uuid) {
+ if let existing = dataManager.gym(withId: uuid) {
+ if gym.updatedAt >= existing.updatedAt {
+ dataManager.updateGym(gym)
+ }
+ } else {
+ dataManager.addGym(gym)
+ }
+ }
+ case "Problem":
+ if let problem = mapRecordToProblem(record, id: uuid) {
+ if let existing = dataManager.problem(withId: uuid) {
+ if problem.updatedAt >= existing.updatedAt {
+ dataManager.updateProblem(problem)
+ }
+ } else {
+ dataManager.addProblem(problem)
+ }
+ }
+ case "ClimbSession":
+ if let session = mapRecordToSession(record, id: uuid) {
+ if let existingIndex = dataManager.sessions.firstIndex(where: { $0.id == uuid }) {
+ let existing = dataManager.sessions[existingIndex]
+ if session.updatedAt >= existing.updatedAt {
+ dataManager.sessions[existingIndex] = session
+ dataManager.saveSessions()
+ }
+ } else {
+ dataManager.sessions.append(session)
+ dataManager.saveSessions()
+ }
+ }
+ case "Attempt":
+ if let attempt = mapRecordToAttempt(record, id: uuid) {
+ if let existingIndex = dataManager.attempts.firstIndex(where: { $0.id == uuid }) {
+ let existing = dataManager.attempts[existingIndex]
+ if attempt.updatedAt >= existing.updatedAt {
+ dataManager.attempts[existingIndex] = attempt
+ dataManager.saveAttempts()
+ }
+ } else {
+ dataManager.attempts.append(attempt)
+ dataManager.saveAttempts()
+ }
+ }
+ default:
+ break
+ }
+ }
+ }
+ }
+
+ // MARK: - Push (Upload to CloudKit)
+
+ private func pushChanges(dataManager: ClimbingDataManager) async throws {
+ let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date ?? Date.distantPast
+ let modifiedGyms = dataManager.gyms.filter { $0.updatedAt > lastSync }
+ let modifiedProblems = dataManager.problems.filter { $0.updatedAt > lastSync }
+ let modifiedSessions = dataManager.sessions.filter { $0.updatedAt > lastSync }
+ let modifiedAttempts = dataManager.attempts.filter { $0.createdAt > lastSync }
+
+ let deletedItems = dataManager.getDeletedItems().filter { item in
+ let dateFormatter = ISO8601DateFormatter()
+ if let date = dateFormatter.date(from: item.deletedAt) {
+ return date > lastSync
+ }
+ return false
+ }
+
+ if modifiedGyms.isEmpty && modifiedProblems.isEmpty && modifiedSessions.isEmpty && modifiedAttempts.isEmpty && deletedItems.isEmpty {
+ AppLogger.info("No local changes to push", tag: logTag)
+ return
+ }
+
+ var recordsToSave: [CKRecord] = []
+ var recordIDsToDelete: [CKRecord.ID] = []
+
+ for item in deletedItems {
+ recordIDsToDelete.append(CKRecord.ID(recordName: item.id, zoneID: zoneID))
+ }
+
+ for gym in modifiedGyms {
+ recordsToSave.append(createRecord(from: gym))
+ }
+ for problem in modifiedProblems {
+ recordsToSave.append(createRecord(from: problem))
+ }
+ for session in modifiedSessions {
+ recordsToSave.append(createRecord(from: session))
+ }
+ for attempt in modifiedAttempts {
+ recordsToSave.append(createRecord(from: attempt))
+ }
+
+ guard !recordsToSave.isEmpty || !recordIDsToDelete.isEmpty else { return }
+
+ AppLogger.info("Pushing to iCloud: \(recordsToSave.count) saves, \(recordIDsToDelete.count) deletions", tag: logTag)
+
+ let operation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
+ operation.savePolicy = .changedKeys // Merges changes if possible, simpler than handling tags manually
+ operation.isAtomic = true // Transactional
+
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ operation.modifyRecordsResultBlock = { result in
+ switch result {
+ case .success:
+ continuation.resume()
+ case .failure(let error):
+ continuation.resume(throwing: error)
+ }
+ }
+ database.add(operation)
+ }
+ }
+
+ // MARK: - Mappers (To CKRecord)
+
+ private func createRecord(from gym: Gym) -> CKRecord {
+ let recordID = CKRecord.ID(recordName: gym.id.uuidString, zoneID: zoneID)
+ let record = CKRecord(recordType: "Gym", recordID: recordID)
+
+ record["name"] = gym.name
+ record["location"] = gym.location
+ record["notes"] = gym.notes
+ record["createdAt"] = gym.createdAt
+ record["updatedAt"] = gym.updatedAt
+
+ record["supportedClimbTypes"] = encode(gym.supportedClimbTypes)
+ record["difficultySystems"] = encode(gym.difficultySystems)
+ record["customDifficultyGrades"] = encode(gym.customDifficultyGrades)
+
+ return record
+ }
+
+ private func createRecord(from problem: Problem) -> CKRecord {
+ let recordID = CKRecord.ID(recordName: problem.id.uuidString, zoneID: zoneID)
+ let record = CKRecord(recordType: "Problem", recordID: recordID)
+
+ record["gymId"] = problem.gymId.uuidString
+ record["name"] = problem.name
+ record["description"] = problem.description
+ record["climbType"] = problem.climbType.rawValue
+ record["difficulty"] = encode(problem.difficulty)
+ record["tags"] = encode(problem.tags)
+ record["location"] = problem.location
+ record["isActive"] = problem.isActive ? 1 : 0
+ record["dateSet"] = problem.dateSet
+ record["notes"] = problem.notes
+ record["createdAt"] = problem.createdAt
+ record["updatedAt"] = problem.updatedAt
+
+ var assets: [CKAsset] = []
+ for path in problem.imagePaths {
+ let fullPath = ImageManager.shared.getFullPath(from: path)
+ let fileURL = URL(fileURLWithPath: fullPath)
+ if FileManager.default.fileExists(atPath: fullPath) {
+ assets.append(CKAsset(fileURL: fileURL))
+ }
+ }
+ if !assets.isEmpty {
+ record["images"] = assets
+ }
+
+ return record
+ }
+
+ private func createRecord(from session: ClimbSession) -> CKRecord {
+ let recordID = CKRecord.ID(recordName: session.id.uuidString, zoneID: zoneID)
+ let record = CKRecord(recordType: "ClimbSession", recordID: recordID)
+
+ record["gymId"] = session.gymId.uuidString
+ record["date"] = session.date
+ record["startTime"] = session.startTime
+ record["endTime"] = session.endTime
+ record["duration"] = session.duration
+ record["status"] = session.status.rawValue
+ record["notes"] = session.notes
+ record["createdAt"] = session.createdAt
+ record["updatedAt"] = session.updatedAt
+
+ return record
+ }
+
+ private func createRecord(from attempt: Attempt) -> CKRecord {
+ let recordID = CKRecord.ID(recordName: attempt.id.uuidString, zoneID: zoneID)
+ let record = CKRecord(recordType: "Attempt", recordID: recordID)
+
+ record["sessionId"] = attempt.sessionId.uuidString
+ record["problemId"] = attempt.problemId.uuidString
+ record["result"] = attempt.result.rawValue
+ record["highestHold"] = attempt.highestHold
+ record["notes"] = attempt.notes
+ record["duration"] = attempt.duration
+ record["restTime"] = attempt.restTime
+ record["timestamp"] = attempt.timestamp
+ record["createdAt"] = attempt.createdAt
+ record["updatedAt"] = attempt.updatedAt
+
+ return record
+ }
+
+ // MARK: - Mappers (From CKRecord)
+
+ private func mapRecordToGym(_ record: CKRecord, id: UUID) -> Gym? {
+ guard let name = record["name"] as? String,
+ let supportedClimbTypesData = record["supportedClimbTypes"] as? String,
+ let difficultySystemsData = record["difficultySystems"] as? String,
+ let createdAt = record["createdAt"] as? Date,
+ let updatedAt = record["updatedAt"] as? Date
+ else { return nil }
+
+ let location = record["location"] as? String
+ let notes = record["notes"] as? String
+ let customGradesData = record["customDifficultyGrades"] as? String ?? "[]"
+
+ let supportedClimbTypes: [ClimbType] = decode(supportedClimbTypesData) ?? []
+ let difficultySystems: [DifficultySystem] = decode(difficultySystemsData) ?? []
+ let customDifficultyGrades: [String] = decode(customGradesData) ?? []
+
+ return Gym.fromImport(
+ id: id,
+ name: name,
+ location: location,
+ supportedClimbTypes: supportedClimbTypes,
+ difficultySystems: difficultySystems,
+ customDifficultyGrades: customDifficultyGrades,
+ notes: notes,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+
+ private func mapRecordToProblem(_ record: CKRecord, id: UUID) -> Problem? {
+ guard let gymIdString = record["gymId"] as? String,
+ let gymId = UUID(uuidString: gymIdString),
+ let climbTypeRaw = record["climbType"] as? String,
+ let climbType = ClimbType(rawValue: climbTypeRaw),
+ let difficultyData = record["difficulty"] as? String,
+ let difficulty: DifficultyGrade = decode(difficultyData),
+ let createdAt = record["createdAt"] as? Date,
+ let updatedAt = record["updatedAt"] as? Date
+ else { return nil }
+
+ let name = record["name"] as? String
+ let description = record["description"] as? String
+ let tagsData = record["tags"] as? String ?? "[]"
+ let tags: [String] = decode(tagsData) ?? []
+ let location = record["location"] as? String
+ let isActive = (record["isActive"] as? Int ?? 1) == 1
+ let dateSet = record["dateSet"] as? Date
+ let notes = record["notes"] as? String
+
+ var imagePaths: [String] = []
+ if let assets = record["images"] as? [CKAsset] {
+ for (index, asset) in assets.enumerated() {
+ guard let fileURL = asset.fileURL else { continue }
+ let filename = ImageNamingUtils.generateImageFilename(problemId: id.uuidString, imageIndex: index)
+
+ if let data = try? Data(contentsOf: fileURL) {
+ _ = try? ImageManager.shared.saveImportedImage(data, filename: filename)
+ imagePaths.append(filename)
+ }
+ }
+ }
+
+ return Problem.fromImport(
+ id: id,
+ gymId: gymId,
+ name: name,
+ description: description,
+ climbType: climbType,
+ difficulty: difficulty,
+ tags: tags,
+ location: location,
+ imagePaths: imagePaths,
+ isActive: isActive,
+ dateSet: dateSet,
+ notes: notes,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+
+ private func mapRecordToSession(_ record: CKRecord, id: UUID) -> ClimbSession? {
+ guard let gymIdString = record["gymId"] as? String,
+ let gymId = UUID(uuidString: gymIdString),
+ let date = record["date"] as? Date,
+ let statusRaw = record["status"] as? String,
+ let status = SessionStatus(rawValue: statusRaw),
+ let createdAt = record["createdAt"] as? Date,
+ let updatedAt = record["updatedAt"] as? Date
+ else { return nil }
+
+ let startTime = record["startTime"] as? Date
+ let endTime = record["endTime"] as? Date
+ let duration = record["duration"] as? Int
+ let notes = record["notes"] as? String
+
+ return ClimbSession.fromImport(
+ id: id,
+ gymId: gymId,
+ date: date,
+ startTime: startTime,
+ endTime: endTime,
+ duration: duration,
+ status: status,
+ notes: notes,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+
+ private func mapRecordToAttempt(_ record: CKRecord, id: UUID) -> Attempt? {
+ guard let sessionIdString = record["sessionId"] as? String,
+ let sessionId = UUID(uuidString: sessionIdString),
+ let problemIdString = record["problemId"] as? String,
+ let problemId = UUID(uuidString: problemIdString),
+ let resultRaw = record["result"] as? String,
+ let result = AttemptResult(rawValue: resultRaw),
+ let timestamp = record["timestamp"] as? Date,
+ let createdAt = record["createdAt"] as? Date,
+ let updatedAt = record["updatedAt"] as? Date
+ else { return nil }
+
+ let highestHold = record["highestHold"] as? String
+ let notes = record["notes"] as? String
+ let duration = record["duration"] as? Int
+ let restTime = record["restTime"] as? Int
+
+ return Attempt.fromImport(
+ id: id,
+ sessionId: sessionId,
+ problemId: problemId,
+ result: result,
+ highestHold: highestHold,
+ notes: notes,
+ duration: duration,
+ restTime: restTime,
+ timestamp: timestamp,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+
+ // MARK: - Helper Coding
+
+ private func encode(_ value: T) -> String {
+ guard let data = try? JSONEncoder().encode(value) else { return "" }
+ return String(data: data, encoding: .utf8) ?? ""
+ }
+
+ private func decode(_ json: String) -> T? {
+ guard let data = json.data(using: .utf8) else { return nil }
+ return try? JSONDecoder().decode(T.self, from: data)
+ }
+}
diff --git a/ios/Ascently/Services/Sync/ServerSyncProvider.swift b/ios/Ascently/Services/Sync/ServerSyncProvider.swift
index 21c9ade..4bdaf68 100644
--- a/ios/Ascently/Services/Sync/ServerSyncProvider.swift
+++ b/ios/Ascently/Services/Sync/ServerSyncProvider.swift
@@ -1,6 +1,6 @@
import Combine
import Foundation
-import UIKit // Needed for UIImage/Data handling if not using ImageManager exclusively
+import UIKit
@MainActor
class ServerSyncProvider: SyncProvider {
@@ -206,7 +206,6 @@ class ServerSyncProvider: SyncProvider {
throw SyncError.invalidURL
}
- // Get last sync time, or use epoch if never synced
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
let formatter = ISO8601DateFormatter()
let lastSyncString = formatter.string(from: lastSync)
@@ -268,7 +267,6 @@ class ServerSyncProvider: SyncProvider {
tag: logTag
)
- // Create delta request
let deltaRequest = DeltaSyncRequest(
lastSyncTime: lastSyncString,
gyms: modifiedGyms,
@@ -316,13 +314,10 @@ class ServerSyncProvider: SyncProvider {
tag: logTag
)
- // Apply server changes to local data
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
- // Upload images for modified problems
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
- // Update last sync time to server time
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
lastSyncTime = serverTime
}
@@ -545,7 +540,6 @@ class ServerSyncProvider: SyncProvider {
}
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
- // Simple mapping
let gyms = dataManager.gyms.map { BackupGym(from: $0) }
let problems = dataManager.problems.map { BackupProblem(from: $0) }
let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) }
diff --git a/ios/Ascently/Services/Sync/SyncProvider.swift b/ios/Ascently/Services/Sync/SyncProvider.swift
index 074fa4f..e9d2933 100644
--- a/ios/Ascently/Services/Sync/SyncProvider.swift
+++ b/ios/Ascently/Services/Sync/SyncProvider.swift
@@ -4,13 +4,13 @@ enum SyncProviderType: String, CaseIterable, Identifiable {
case none
case server
case iCloud
-
+
var id: String { rawValue }
var displayName: String {
switch self {
case .none: return "None"
case .server: return "Self-Hosted Server"
- case .iCloud: return "iCloud"
+ case .iCloud: return "iCloud (BETA)"
}
}
}
@@ -19,7 +19,8 @@ protocol SyncProvider {
var type: SyncProviderType { get }
var isConfigured: Bool { get }
var isConnected: Bool { get }
-
+ var lastSyncTime: Date? { get }
+
func sync(dataManager: ClimbingDataManager) async throws
func testConnection() async throws
func disconnect()
diff --git a/ios/Ascently/Services/SyncService.swift b/ios/Ascently/Services/SyncService.swift
index 9cd8133..8d395d8 100644
--- a/ios/Ascently/Services/SyncService.swift
+++ b/ios/Ascently/Services/SyncService.swift
@@ -58,9 +58,6 @@ class SyncService: ObservableObject {
}
init() {
- if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
- self.lastSyncTime = lastSync
- }
isConnected = userDefaults.bool(forKey: Keys.isConnected)
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
@@ -80,8 +77,7 @@ class SyncService: ObservableObject {
case .server:
activeProvider = ServerSyncProvider()
case .iCloud:
- // Placeholder for iCloud provider
- activeProvider = nil
+ activeProvider = ICloudSyncProvider()
case .none:
activeProvider = nil
}
@@ -89,8 +85,10 @@ class SyncService: ObservableObject {
// Update status based on new provider
if let provider = activeProvider {
isConnected = provider.isConnected
+ lastSyncTime = provider.lastSyncTime
} else {
isConnected = false
+ lastSyncTime = nil
}
}
@@ -127,10 +125,7 @@ class SyncService: ObservableObject {
try await provider.sync(dataManager: dataManager)
// Update last sync time
- // Provider might have updated it in UserDefaults, reload it
- if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
- self.lastSyncTime = lastSync
- }
+ self.lastSyncTime = provider.lastSyncTime
} catch {
syncError = error.localizedDescription
throw error
@@ -204,7 +199,6 @@ class SyncService: ObservableObject {
// These are shared keys, so clearing them affects all providers if they use them
// But disconnect() is usually user initiated action
userDefaults.set(false, forKey: Keys.isConnected)
- userDefaults.removeObject(forKey: Keys.lastSyncTime)
}
func clearConfiguration() {
diff --git a/ios/Ascently/ViewModels/ClimbingDataManager.swift b/ios/Ascently/ViewModels/ClimbingDataManager.swift
index 20deb6e..6516665 100644
--- a/ios/Ascently/ViewModels/ClimbingDataManager.swift
+++ b/ios/Ascently/ViewModels/ClimbingDataManager.swift
@@ -306,8 +306,6 @@ class ClimbingDataManager: ObservableObject {
gyms.append(gym)
saveGyms()
DataStateManager.shared.updateDataState()
- successMessage = "Gym added successfully"
- clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -318,8 +316,6 @@ class ClimbingDataManager: ObservableObject {
gyms[index] = gym
saveGyms()
DataStateManager.shared.updateDataState()
- successMessage = "Gym updated successfully"
- clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -344,8 +340,6 @@ class ClimbingDataManager: ObservableObject {
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)
@@ -359,8 +353,6 @@ class ClimbingDataManager: ObservableObject {
problems.append(problem)
saveProblems()
DataStateManager.shared.updateDataState()
- successMessage = "Problem added successfully"
- clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -371,8 +363,6 @@ class ClimbingDataManager: ObservableObject {
problems[index] = problem
saveProblems()
DataStateManager.shared.updateDataState()
- successMessage = "Problem updated successfully"
- clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift
index f109159..903a81c 100644
--- a/ios/Ascently/Views/SettingsView.swift
+++ b/ios/Ascently/Views/SettingsView.swift
@@ -555,7 +555,7 @@ struct SyncSection: View {
: .red
)
VStack(alignment: .leading) {
- Text("Sync Server")
+ Text(syncService.providerType == .iCloud ? "iCloud Sync" : "Sync Server")
.font(.headline)
Text(
syncService.isConnected
@@ -570,14 +570,14 @@ struct SyncSection: View {
Spacer()
}
- // Configure Server
+ // Configure Sync
Button(action: {
activeSheet = .syncSettings
}) {
HStack {
Image(systemName: "gear")
.foregroundColor(themeManager.accentColor)
- Text("Configure Server")
+ Text("Configure Sync")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
@@ -698,63 +698,78 @@ struct SyncSettingsView: View {
@State private var isTesting = false
@State private var showingTestResult = false
@State private var testResultMessage = ""
+ @State private var selectedProvider: SyncProviderType = .server
var body: some View {
NavigationStack {
Form {
Section {
- TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
- .keyboardType(.URL)
- .autocapitalization(.none)
- .disableAutocorrection(true)
-
- TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token"))
- .autocapitalization(.none)
- .disableAutocorrection(true)
+ Picker("Provider", selection: $selectedProvider) {
+ ForEach(SyncProviderType.allCases) { type in
+ Text(type.displayName).tag(type)
+ }
+ }
} header: {
- Text("Server Configuration")
- } footer: {
- Text(
- "Enter your sync server URL and authentication token. You must test the connection before syncing is available."
- )
+ Text("Sync Provider")
}
- Section {
- Button(action: {
- testConnection()
- }) {
- HStack {
- if isTesting {
- ProgressView()
- .scaleEffect(0.8)
- Text("Testing...")
- .foregroundColor(.secondary)
- } else {
- Image(systemName: "network")
- .foregroundColor(themeManager.accentColor)
- Text("Test Connection")
- Spacer()
- if syncService.isConnected {
- Image(systemName: "checkmark.circle.fill")
- .foregroundColor(.green)
+ if selectedProvider == .server {
+ Section {
+ TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
+ .keyboardType(.URL)
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+
+ TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token"))
+ .autocapitalization(.none)
+ .disableAutocorrection(true)
+ } header: {
+ Text("Server Configuration")
+ } footer: {
+ Text(
+ "Enter your sync server URL and authentication token. You must test the connection before syncing is available."
+ )
+ }
+ }
+
+ if selectedProvider != .none {
+ Section {
+ Button(action: {
+ testConnection()
+ }) {
+ HStack {
+ if isTesting {
+ ProgressView()
+ .scaleEffect(0.8)
+ Text("Testing...")
+ .foregroundColor(.secondary)
+ } else {
+ Image(systemName: "network")
+ .foregroundColor(themeManager.accentColor)
+ Text("Test Connection")
+ Spacer()
+ if syncService.isConnected && syncService.providerType == selectedProvider {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ }
}
}
}
+ .disabled(
+ isTesting
+ || (selectedProvider == .server && (serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
+ )
+ .foregroundColor(.primary)
+ } header: {
+ Text("Connection")
+ } footer: {
+ Text("Test the connection to verify your settings before saving.")
}
- .disabled(
- isTesting
- || serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- )
- .foregroundColor(.primary)
- } header: {
- Text("Connection")
- } footer: {
- Text("Test the connection to verify your server settings before saving.")
}
Section {
- Button("Disconnect from Server") {
+ Button("Disconnect") {
showingDisconnectAlert = true
}
.foregroundColor(.orange)
@@ -785,18 +800,17 @@ struct SyncSettingsView: View {
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed
- if newURL != syncService.serverURL || newToken != syncService.authToken {
+ if selectedProvider == .server && (newURL != syncService.serverURL || newToken != syncService.authToken) {
+ syncService.isConnected = false
+ UserDefaults.standard.set(false, forKey: "sync_is_connected")
+ } else if selectedProvider != syncService.providerType {
syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected")
}
syncService.serverURL = newURL
syncService.authToken = newToken
-
- // Ensure provider type is set to server
- if syncService.providerType != .server {
- syncService.providerType = .server
- }
+ syncService.providerType = selectedProvider
dismiss()
}
@@ -807,6 +821,7 @@ struct SyncSettingsView: View {
.onAppear {
serverURL = syncService.serverURL
authToken = syncService.authToken
+ selectedProvider = syncService.providerType
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {}
@@ -835,17 +850,19 @@ struct SyncSettingsView: View {
// Store original values in case test fails
let originalURL = syncService.serverURL
let originalToken = syncService.authToken
+ let originalProvider = syncService.providerType
Task { @MainActor in
do {
- // Ensure we are using the server provider
- if syncService.providerType != .server {
- syncService.providerType = .server
+ // Switch to selected provider
+ if syncService.providerType != selectedProvider {
+ syncService.providerType = selectedProvider
}
- // Temporarily set the values for testing
- syncService.serverURL = testURL
- syncService.authToken = testToken
+ if selectedProvider == .server {
+ syncService.serverURL = testURL
+ syncService.authToken = testToken
+ }
// Explicitly sync UserDefaults to ensure immediate availability
UserDefaults.standard.synchronize()
@@ -858,8 +875,11 @@ struct SyncSettingsView: View {
showingTestResult = true
} catch {
// Restore original values if test failed
- syncService.serverURL = originalURL
- syncService.authToken = originalToken
+ syncService.providerType = originalProvider
+ if originalProvider == .server {
+ syncService.serverURL = originalURL
+ syncService.authToken = originalToken
+ }
isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)"
diff --git a/ios/README.md b/ios/README.md
index 3facd49..18918b2 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -1,6 +1,6 @@
# Ascently for iOS
-The native iOS and widget client for Ascently, built with Swift and SwiftUI.
+The native iOS app and widget for Ascently, built with Swift and SwiftUI.
## Project Structure