Compare commits

...

9 Commits

Author SHA1 Message Date
c020287d1f Bumped build number 2026-01-12 10:01:13 -07:00
98589645e6 iOS 2.7.0 - BETA Release of the iCloud Sync provider! 2026-01-12 09:59:44 -07:00
33610a5959 Moar
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 4m21s
2026-01-11 02:06:50 -07:00
20058e9ac0 Optimizations
Some checks failed
Ascently - Docs Deploy / build-and-push (push) Failing after 2m51s
2026-01-11 01:58:52 -07:00
e4d6e6fb7e Added Balls PNG files 2026-01-10 15:24:51 -07:00
d97a5f36ea pls
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m58s
2026-01-10 02:03:24 -07:00
1a85dab6ae Update README.md 2026-01-10 08:33:51 +00:00
2d5382ba28 Updated logo for docs + deps
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m59s
2026-01-10 01:30:30 -07:00
05c0430b40 Added iPad branding 2026-01-09 23:07:00 -07:00
33 changed files with 1067 additions and 483 deletions

View File

@@ -1,5 +1,7 @@
# Ascently
<img src="https://git.atri.dad/atridad/Ascently/raw/branch/main/docs/src/assets/logo.svg" alt="Ascently Logo" width="250" height="250">
_Formerly OpenClimb_
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -6,53 +6,63 @@ import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
site: "https://docs.ascently.app",
site: "https://docs.ascently.app",
integrations: [
starlight({
title: "Ascently",
description:
"An offline-first FOSS climb tracking app with an optional sync server.",
logo: {
light: "./src/assets/logo.svg",
dark: "./src/assets/logo-dark.svg",
},
favicon: "/favicon.png",
social: [
{
icon: "seti:git",
label: "Gitea",
href: "https://git.atri.dad/atridad/Ascently",
},
{
icon: "email",
label: "Contact",
href: "mailto:me@atri.dad",
},
],
sidebar: [
{
label: "Download",
link: "/download/",
},
{
label: "Self-Hosted Sync",
items: [
{ label: "Overview", slug: "sync/overview" },
{ label: "Quick Start", slug: "sync/quick-start" },
{ label: "API Reference", slug: "sync/api-reference" },
],
},
{
label: "Privacy",
link: "/privacy/",
},
],
customCss: ["./src/styles/custom.css"],
integrations: [
starlight({
title: "Ascently",
description:
"An offline-first FOSS climb tracking app with an optional sync server.",
logo: {
light: "./src/assets/logo.svg",
dark: "./src/assets/logo.svg",
},
favicon: "/favicon.png",
social: [
{
icon: "seti:git",
label: "Gitea",
href: "https://git.atri.dad/atridad/Ascently",
},
{
icon: "email",
label: "Contact",
href: "mailto:me@atri.dad",
},
],
sidebar: [
{
label: "Download",
link: "/download/",
},
{
label: "Self-Hosted Sync",
items: [
{ label: "Overview", slug: "sync/overview" },
{ label: "Quick Start", slug: "sync/quick-start" },
{ label: "API Reference", slug: "sync/api-reference" },
],
},
{
label: "Privacy",
link: "/privacy/",
},
],
customCss: ["./src/styles/custom.css"],
}),
],
adapter: node({
mode: "standalone",
}),
],
adapter: node({
mode: "standalone",
}),
output: "server",
build: {
inlineStylesheets: "always",
},
experimental: {
svgo: true,
},
});

View File

@@ -26,8 +26,8 @@
},
"dependencies": {
"@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.37.1",
"astro": "^5.16.5",
"@astrojs/starlight": "^0.37.2",
"astro": "^5.16.8",
"qrcode": "^1.5.4",
"sharp": "^0.34.5"
},

593
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -1,4 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 244 B

View File

@@ -1,4 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<polygon points="24.000,213.091 85.884,73.851 147.769,213.091" fill="#FFC107"/>
<polygon points="77.289,213.091 154.645,42.909 232.000,213.091" fill="#F44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 259 B

BIN
docs/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -5,7 +5,7 @@ template: splash
hero:
tagline: Track your climbing sessions, routes, and progress.
image:
file: ../../assets/logo-highres.svg
file: ../../assets/logo.svg
alt: "Ascently app icon"
actions:
- text: Download

31
docs/src/middleware.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (_, next) => {
const response = await next();
const contentType = response.headers.get("Content-Type") || "";
// Only modify HTML responses
if (contentType.includes("text/html")) {
const html = await response.text();
// Optimize LCP image by setting fetchpriority="high" on the hero image
// Target specific image by its alt text seen in PageSpeed Insights
const optimizedHtml = html.replace(
/<img([^>]*?)alt="Ascently app icon"([^>]*?)>/,
(match, p1, p2) => {
if (match.includes("fetchpriority=")) {
return match.replace(/fetchpriority="[^"]*"/, 'fetchpriority="high"');
}
return `<img${p1}alt="Ascently app icon" fetchpriority="high"${p2}>`;
}
);
return new Response(optimizedHtml, {
status: response.status,
headers: response.headers,
});
}
return response;
});

View File

@@ -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 = 46;
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 = 46;
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 = 46;
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 = 46;
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;

View File

@@ -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>

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 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) }

View File

@@ -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()

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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)"

View File

@@ -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