iOS 2.7.0 - BETA Release of the iCloud Sync provider!
This commit is contained in:
@@ -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;
|
||||
|
||||
Binary file not shown.
@@ -2,13 +2,25 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.atridad.Ascently</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
584
ios/Ascently/Services/Sync/ICloudSyncProvider.swift
Normal file
584
ios/Ascently/Services/Sync/ICloudSyncProvider.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -10,7 +10,7 @@ enum SyncProviderType: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
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 isConfigured: Bool { get }
|
||||
var isConnected: Bool { get }
|
||||
var lastSyncTime: Date? { get }
|
||||
|
||||
func sync(dataManager: ClimbingDataManager) async throws
|
||||
func testConnection() async throws
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user