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