iOS 2.7.0 - BETA Release of the iCloud Sync provider!

This commit is contained in:
2026-01-12 09:59:44 -07:00
parent 33610a5959
commit 98589645e6
10 changed files with 697 additions and 102 deletions

View File

@@ -466,7 +466,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -491,7 +491,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.6.1; MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -518,7 +518,7 @@
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements; CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6; DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -543,7 +543,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.6; MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.6.1; MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -610,7 +610,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -622,7 +622,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.6.1; MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -641,7 +641,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44; CURRENT_PROJECT_VERSION = 45;
DEVELOPMENT_TEAM = 4BC9Y2LL4B; DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist; INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -653,7 +653,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2.6.1; MARKETING_VERSION = 2.7.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@@ -2,13 +2,25 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>aps-environment</key>
<array> <string>development</string>
<string>group.com.atridad.Ascently</string>
</array>
<key>com.apple.developer.healthkit</key> <key>com.apple.developer.healthkit</key>
<true/> <true/>
<key>com.apple.developer.healthkit.access</key> <key>com.apple.developer.healthkit.access</key>
<array/> <array/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.atridad.Ascently</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.atridad.Ascently</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.atridad.Ascently</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -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<Void, Error>) 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<T: Encodable>(_ value: T) -> String {
guard let data = try? JSONEncoder().encode(value) else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
private func decode<T: Decodable>(_ json: String) -> T? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}

View File

@@ -1,6 +1,6 @@
import Combine import Combine
import Foundation import Foundation
import UIKit // Needed for UIImage/Data handling if not using ImageManager exclusively import UIKit
@MainActor @MainActor
class ServerSyncProvider: SyncProvider { class ServerSyncProvider: SyncProvider {
@@ -206,7 +206,6 @@ class ServerSyncProvider: SyncProvider {
throw SyncError.invalidURL throw SyncError.invalidURL
} }
// Get last sync time, or use epoch if never synced
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0) let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
let lastSyncString = formatter.string(from: lastSync) let lastSyncString = formatter.string(from: lastSync)
@@ -268,7 +267,6 @@ class ServerSyncProvider: SyncProvider {
tag: logTag tag: logTag
) )
// Create delta request
let deltaRequest = DeltaSyncRequest( let deltaRequest = DeltaSyncRequest(
lastSyncTime: lastSyncString, lastSyncTime: lastSyncString,
gyms: modifiedGyms, gyms: modifiedGyms,
@@ -316,13 +314,10 @@ class ServerSyncProvider: SyncProvider {
tag: logTag tag: logTag
) )
// Apply server changes to local data
try await applyDeltaResponse(deltaResponse, dataManager: dataManager) try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
// Upload images for modified problems
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager) try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
// Update last sync time to server time
if let serverTime = formatter.date(from: deltaResponse.serverTime) { if let serverTime = formatter.date(from: deltaResponse.serverTime) {
lastSyncTime = serverTime lastSyncTime = serverTime
} }
@@ -545,7 +540,6 @@ class ServerSyncProvider: SyncProvider {
} }
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup { private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
// Simple mapping
let gyms = dataManager.gyms.map { BackupGym(from: $0) } let gyms = dataManager.gyms.map { BackupGym(from: $0) }
let problems = dataManager.problems.map { BackupProblem(from: $0) } let problems = dataManager.problems.map { BackupProblem(from: $0) }
let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) } let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) }

View File

@@ -10,7 +10,7 @@ enum SyncProviderType: String, CaseIterable, Identifiable {
switch self { switch self {
case .none: return "None" case .none: return "None"
case .server: return "Self-Hosted Server" case .server: return "Self-Hosted Server"
case .iCloud: return "iCloud" case .iCloud: return "iCloud (BETA)"
} }
} }
} }
@@ -19,6 +19,7 @@ protocol SyncProvider {
var type: SyncProviderType { get } var type: SyncProviderType { get }
var isConfigured: Bool { get } var isConfigured: Bool { get }
var isConnected: Bool { get } var isConnected: Bool { get }
var lastSyncTime: Date? { get }
func sync(dataManager: ClimbingDataManager) async throws func sync(dataManager: ClimbingDataManager) async throws
func testConnection() async throws func testConnection() async throws

View File

@@ -58,9 +58,6 @@ class SyncService: ObservableObject {
} }
init() { init() {
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
isConnected = userDefaults.bool(forKey: Keys.isConnected) isConnected = userDefaults.bool(forKey: Keys.isConnected)
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode) isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
@@ -80,8 +77,7 @@ class SyncService: ObservableObject {
case .server: case .server:
activeProvider = ServerSyncProvider() activeProvider = ServerSyncProvider()
case .iCloud: case .iCloud:
// Placeholder for iCloud provider activeProvider = ICloudSyncProvider()
activeProvider = nil
case .none: case .none:
activeProvider = nil activeProvider = nil
} }
@@ -89,8 +85,10 @@ class SyncService: ObservableObject {
// Update status based on new provider // Update status based on new provider
if let provider = activeProvider { if let provider = activeProvider {
isConnected = provider.isConnected isConnected = provider.isConnected
lastSyncTime = provider.lastSyncTime
} else { } else {
isConnected = false isConnected = false
lastSyncTime = nil
} }
} }
@@ -127,10 +125,7 @@ class SyncService: ObservableObject {
try await provider.sync(dataManager: dataManager) try await provider.sync(dataManager: dataManager)
// Update last sync time // Update last sync time
// Provider might have updated it in UserDefaults, reload it self.lastSyncTime = provider.lastSyncTime
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
self.lastSyncTime = lastSync
}
} catch { } catch {
syncError = error.localizedDescription syncError = error.localizedDescription
throw error throw error
@@ -204,7 +199,6 @@ class SyncService: ObservableObject {
// These are shared keys, so clearing them affects all providers if they use them // These are shared keys, so clearing them affects all providers if they use them
// But disconnect() is usually user initiated action // But disconnect() is usually user initiated action
userDefaults.set(false, forKey: Keys.isConnected) userDefaults.set(false, forKey: Keys.isConnected)
userDefaults.removeObject(forKey: Keys.lastSyncTime)
} }
func clearConfiguration() { func clearConfiguration() {

View File

@@ -306,8 +306,6 @@ class ClimbingDataManager: ObservableObject {
gyms.append(gym) gyms.append(gym)
saveGyms() saveGyms()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled // Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
@@ -318,8 +316,6 @@ class ClimbingDataManager: ObservableObject {
gyms[index] = gym gyms[index] = gym
saveGyms() saveGyms()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled // Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
@@ -344,8 +340,6 @@ class ClimbingDataManager: ObservableObject {
trackDeletion(itemId: gym.id.uuidString, itemType: "gym") trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
saveGyms() saveGyms()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled // Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
@@ -359,8 +353,6 @@ class ClimbingDataManager: ObservableObject {
problems.append(problem) problems.append(problem)
saveProblems() saveProblems()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Problem added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled // Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
@@ -371,8 +363,6 @@ class ClimbingDataManager: ObservableObject {
problems[index] = problem problems[index] = problem
saveProblems() saveProblems()
DataStateManager.shared.updateDataState() DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled // Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)

View File

@@ -555,7 +555,7 @@ struct SyncSection: View {
: .red : .red
) )
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Sync Server") Text(syncService.providerType == .iCloud ? "iCloud Sync" : "Sync Server")
.font(.headline) .font(.headline)
Text( Text(
syncService.isConnected syncService.isConnected
@@ -570,14 +570,14 @@ struct SyncSection: View {
Spacer() Spacer()
} }
// Configure Server // Configure Sync
Button(action: { Button(action: {
activeSheet = .syncSettings activeSheet = .syncSettings
}) { }) {
HStack { HStack {
Image(systemName: "gear") Image(systemName: "gear")
.foregroundColor(themeManager.accentColor) .foregroundColor(themeManager.accentColor)
Text("Configure Server") Text("Configure Sync")
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
@@ -698,10 +698,22 @@ struct SyncSettingsView: View {
@State private var isTesting = false @State private var isTesting = false
@State private var showingTestResult = false @State private var showingTestResult = false
@State private var testResultMessage = "" @State private var testResultMessage = ""
@State private var selectedProvider: SyncProviderType = .server
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section {
Picker("Provider", selection: $selectedProvider) {
ForEach(SyncProviderType.allCases) { type in
Text(type.displayName).tag(type)
}
}
} header: {
Text("Sync Provider")
}
if selectedProvider == .server {
Section { Section {
TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080")) TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
.keyboardType(.URL) .keyboardType(.URL)
@@ -718,7 +730,9 @@ struct SyncSettingsView: View {
"Enter your sync server URL and authentication token. You must test the connection before syncing is available." "Enter your sync server URL and authentication token. You must test the connection before syncing is available."
) )
} }
}
if selectedProvider != .none {
Section { Section {
Button(action: { Button(action: {
testConnection() testConnection()
@@ -734,7 +748,7 @@ struct SyncSettingsView: View {
.foregroundColor(themeManager.accentColor) .foregroundColor(themeManager.accentColor)
Text("Test Connection") Text("Test Connection")
Spacer() Spacer()
if syncService.isConnected { if syncService.isConnected && syncService.providerType == selectedProvider {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
} }
@@ -743,18 +757,19 @@ struct SyncSettingsView: View {
} }
.disabled( .disabled(
isTesting isTesting
|| serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (selectedProvider == .server && (serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
) )
.foregroundColor(.primary) .foregroundColor(.primary)
} header: { } header: {
Text("Connection") Text("Connection")
} footer: { } footer: {
Text("Test the connection to verify your server settings before saving.") Text("Test the connection to verify your settings before saving.")
}
} }
Section { Section {
Button("Disconnect from Server") { Button("Disconnect") {
showingDisconnectAlert = true showingDisconnectAlert = true
} }
.foregroundColor(.orange) .foregroundColor(.orange)
@@ -785,18 +800,17 @@ struct SyncSettingsView: View {
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed // 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 syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected") UserDefaults.standard.set(false, forKey: "sync_is_connected")
} }
syncService.serverURL = newURL syncService.serverURL = newURL
syncService.authToken = newToken syncService.authToken = newToken
syncService.providerType = selectedProvider
// Ensure provider type is set to server
if syncService.providerType != .server {
syncService.providerType = .server
}
dismiss() dismiss()
} }
@@ -807,6 +821,7 @@ struct SyncSettingsView: View {
.onAppear { .onAppear {
serverURL = syncService.serverURL serverURL = syncService.serverURL
authToken = syncService.authToken authToken = syncService.authToken
selectedProvider = syncService.providerType
} }
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
@@ -835,17 +850,19 @@ struct SyncSettingsView: View {
// Store original values in case test fails // Store original values in case test fails
let originalURL = syncService.serverURL let originalURL = syncService.serverURL
let originalToken = syncService.authToken let originalToken = syncService.authToken
let originalProvider = syncService.providerType
Task { @MainActor in Task { @MainActor in
do { do {
// Ensure we are using the server provider // Switch to selected provider
if syncService.providerType != .server { if syncService.providerType != selectedProvider {
syncService.providerType = .server syncService.providerType = selectedProvider
} }
// Temporarily set the values for testing if selectedProvider == .server {
syncService.serverURL = testURL syncService.serverURL = testURL
syncService.authToken = testToken syncService.authToken = testToken
}
// Explicitly sync UserDefaults to ensure immediate availability // Explicitly sync UserDefaults to ensure immediate availability
UserDefaults.standard.synchronize() UserDefaults.standard.synchronize()
@@ -858,8 +875,11 @@ struct SyncSettingsView: View {
showingTestResult = true showingTestResult = true
} catch { } catch {
// Restore original values if test failed // Restore original values if test failed
syncService.providerType = originalProvider
if originalProvider == .server {
syncService.serverURL = originalURL syncService.serverURL = originalURL
syncService.authToken = originalToken syncService.authToken = originalToken
}
isTesting = false isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)" testResultMessage = "Connection failed: \(error.localizedDescription)"

View File

@@ -1,6 +1,6 @@
# Ascently for iOS # 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 ## Project Structure