Compare commits

...

11 Commits

Author SHA1 Message Date
394789d609 Balls. 2026-01-12 18:13:22 -07:00
94566eabf6 Update docs logo
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m51s
2026-01-12 10:46:22 -07:00
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
47 changed files with 1251 additions and 486 deletions

View File

@@ -1,5 +1,7 @@
# Ascently # Ascently
<img src="https://git.atri.dad/atridad/Ascently/raw/branch/main/docs/src/assets/logo.png" alt="Ascently Logo" width="250" height="250">
_Formerly OpenClimb_ _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. 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

@@ -14,8 +14,8 @@ export default defineConfig({
description: description:
"An offline-first FOSS climb tracking app with an optional sync server.", "An offline-first FOSS climb tracking app with an optional sync server.",
logo: { logo: {
light: "./src/assets/logo.svg", light: "./src/assets/logo.png",
dark: "./src/assets/logo-dark.svg", dark: "./src/assets/logo.png",
}, },
favicon: "/favicon.png", favicon: "/favicon.png",
social: [ social: [
@@ -55,4 +55,14 @@ export default defineConfig({
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),
output: "server",
build: {
inlineStylesheets: "always",
},
experimental: {
svgo: true,
},
}); });

View File

@@ -26,8 +26,8 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.5.1", "@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.37.1", "@astrojs/starlight": "^0.37.2",
"astro": "^5.16.5", "astro": "^5.16.8",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5" "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: hero:
tagline: Track your climbing sessions, routes, and progress. tagline: Track your climbing sessions, routes, and progress.
image: image:
file: ../../assets/logo-highres.svg file: ../../assets/logo.png
alt: "Ascently app icon" alt: "Ascently app icon"
actions: actions:
- text: Download - 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

@@ -1,6 +1,4 @@
# SwiftFormat Configuration for Ascently iOS # SwiftFormat Configuration for Ascently iOS
# Maintains consistent formatting across the project
# File options # File options
--exclude build,Pods,DerivedData,.build --exclude build,Pods,DerivedData,.build

View File

@@ -92,7 +92,6 @@ identifier_name:
- DATA_JSON_FILENAME - DATA_JSON_FILENAME
- IMAGES_DIR_NAME - IMAGES_DIR_NAME
- METADATA_FILENAME - METADATA_FILENAME
# ViewBuilder section functions (SwiftUI convention)
- StatusSection - StatusSection
- IconDisplaySection - IconDisplaySection
- DebugSection - DebugSection

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "AscentlyBlueBall.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "AscentlyGreenBall.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "AscentlyRedBall.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "AscentlyYellowBall.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "AscetlyTriangle1.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,21 @@
{
"images": [
{
"idiom": "universal",
"filename": "AscetlyTriangle2.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

View File

@@ -0,0 +1,584 @@
import CloudKit
import Foundation
import UIKit
class ICloudSyncProvider: SyncProvider {
// MARK: - Properties
var type: SyncProviderType { .iCloud }
var isConfigured: Bool {
return true
}
var isConnected: Bool {
get { userDefaults.bool(forKey: Keys.isConnected) }
set { userDefaults.set(newValue, forKey: Keys.isConnected) }
}
var lastSyncTime: Date? {
return userDefaults.object(forKey: Keys.lastSyncTime) as? Date
}
private let container = CKContainer.default()
private lazy var database = container.privateCloudDatabase
private let zoneID = CKRecordZone.ID(zoneName: "AscentlyZone", ownerName: CKCurrentUserDefaultName)
private let userDefaults = UserDefaults.standard
private let logTag = "ICloudSync"
private enum Keys {
static let serverChangeToken = "Ascently.ICloud.ServerChangeToken"
static let lastSyncTime = "Ascently.ICloud.LastSyncTime"
static let zoneCreated = "Ascently.ICloud.ZoneCreated"
static let isConnected = "Ascently.ICloud.IsConnected"
}
// MARK: - Init
init() {
Task {
try? await checkAccountStatus()
}
}
// MARK: - SyncProvider Protocol
func testConnection() async throws {
try await checkAccountStatus()
}
func disconnect() {
isConnected = false
}
func sync(dataManager: ClimbingDataManager) async throws {
if !isConnected {
try await checkAccountStatus()
if !isConnected {
throw SyncError.notConnected
}
}
AppLogger.info("Starting iCloud sync", tag: logTag)
do {
try await createZoneIfNeeded()
do {
try await pullChanges(dataManager: dataManager)
} catch {
let errorString = String(describing: error)
if errorString.contains("recordName") && errorString.contains("queryable") {
AppLogger.warning("Schema initialization detected. Skipping pull to attempt self-healing via push.", tag: logTag)
} else {
throw error
}
}
try await pushChanges(dataManager: dataManager)
userDefaults.set(Date(), forKey: Keys.lastSyncTime)
AppLogger.info("iCloud sync completed successfully", tag: logTag)
} catch {
AppLogger.error("iCloud sync failed: \(error.localizedDescription)", tag: logTag)
throw error
}
}
// MARK: - Private Methods
private func checkAccountStatus() async throws {
let status: CKAccountStatus
do {
status = try await container.accountStatus()
} catch {
AppLogger.error("Failed to get iCloud account status: \(error). This often indicates a mismatch between the App Bundle ID and the iCloud Container configuration in the Provisioning Profile.", tag: logTag)
throw error
}
AppLogger.info("iCloud account status: \(status.rawValue)", tag: logTag)
switch status {
case .available:
isConnected = true
case .noAccount:
isConnected = false
throw SyncError.providerError("No iCloud account found. Please sign in to iCloud settings.")
case .restricted:
isConnected = false
throw SyncError.providerError("iCloud access is restricted.")
case .couldNotDetermine:
isConnected = false
throw SyncError.providerError("Could not determine iCloud account status.")
case .temporarilyUnavailable:
isConnected = false
throw SyncError.providerError("iCloud is temporarily unavailable.")
@unknown default:
isConnected = false
throw SyncError.providerError("Unknown iCloud error.")
}
}
private func createZoneIfNeeded() async throws {
if userDefaults.bool(forKey: Keys.zoneCreated) {
return
}
do {
let zone = CKRecordZone(zoneID: zoneID)
try await database.save(zone)
userDefaults.set(true, forKey: Keys.zoneCreated)
AppLogger.info("Created custom record zone: \(zoneID.zoneName)", tag: logTag)
} catch {
// If zone already exists, that's fine
if let ckError = error as? CKError {
if ckError.code == .serverRecordChanged {
userDefaults.set(true, forKey: Keys.zoneCreated)
return
}
if ckError.code == .permissionFailure {
AppLogger.error("CloudKit Permission Failure. This usually indicates 'Invalid Bundle ID'. Please Clean Build Folder and ensure Provisioning Profile matches entitlements.", tag: logTag)
}
}
throw error
}
}
// MARK: - Pull (Fetch from CloudKit)
private func pullChanges(dataManager: ClimbingDataManager) async throws {
var previousToken: CKServerChangeToken? = nil
if let tokenData = userDefaults.data(forKey: Keys.serverChangeToken) {
previousToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
}
var changedRecords: [CKRecord] = []
var deletedRecordIDs: [CKRecord.ID] = []
let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
config.previousServerChangeToken = previousToken
return try await withCheckedThrowingContinuation { continuation in
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID], configurationsByRecordZoneID: [zoneID: config])
operation.recordWasChangedBlock = { recordID, result in
switch result {
case .success(let record):
changedRecords.append(record)
case .failure(let error):
AppLogger.error("Failed to fetch record \(recordID): \(error)", tag: self.logTag)
}
}
operation.recordWithIDWasDeletedBlock = { recordID, _ in
deletedRecordIDs.append(recordID)
}
operation.recordZoneChangeTokensUpdatedBlock = { zoneID, token, _ in
if let token = token {
self.saveChangeToken(token)
}
}
operation.fetchRecordZoneChangesResultBlock = { result in
switch result {
case .success:
Task {
await self.applyChanges(changedRecords: changedRecords, deletedRecordIDs: deletedRecordIDs, dataManager: dataManager)
continuation.resume()
}
case .failure(let error):
continuation.resume(throwing: error)
}
}
database.add(operation)
}
}
private func saveChangeToken(_ token: CKServerChangeToken) {
if let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) {
userDefaults.set(data, forKey: Keys.serverChangeToken)
}
}
private func applyChanges(changedRecords: [CKRecord], deletedRecordIDs: [CKRecord.ID], dataManager: ClimbingDataManager) async {
guard !changedRecords.isEmpty || !deletedRecordIDs.isEmpty else { return }
AppLogger.info("Applying CloudKit changes: \(changedRecords.count) updates, \(deletedRecordIDs.count) deletions", tag: logTag)
await MainActor.run {
for recordID in deletedRecordIDs {
let uuidString = recordID.recordName
if let uuid = UUID(uuidString: uuidString) {
if let gym = dataManager.gym(withId: uuid) { dataManager.deleteGym(gym) }
else if let problem = dataManager.problem(withId: uuid) { dataManager.deleteProblem(problem) }
else if let session = dataManager.session(withId: uuid) { dataManager.deleteSession(session) }
else if let attempt = dataManager.attempts.first(where: { $0.id == uuid }) { dataManager.deleteAttempt(attempt) }
}
}
for record in changedRecords {
guard let uuid = UUID(uuidString: record.recordID.recordName) else { continue }
switch record.recordType {
case "Gym":
if let gym = mapRecordToGym(record, id: uuid) {
if let existing = dataManager.gym(withId: uuid) {
if gym.updatedAt >= existing.updatedAt {
dataManager.updateGym(gym)
}
} else {
dataManager.addGym(gym)
}
}
case "Problem":
if let problem = mapRecordToProblem(record, id: uuid) {
if let existing = dataManager.problem(withId: uuid) {
if problem.updatedAt >= existing.updatedAt {
dataManager.updateProblem(problem)
}
} else {
dataManager.addProblem(problem)
}
}
case "ClimbSession":
if let session = mapRecordToSession(record, id: uuid) {
if let existingIndex = dataManager.sessions.firstIndex(where: { $0.id == uuid }) {
let existing = dataManager.sessions[existingIndex]
if session.updatedAt >= existing.updatedAt {
dataManager.sessions[existingIndex] = session
dataManager.saveSessions()
}
} else {
dataManager.sessions.append(session)
dataManager.saveSessions()
}
}
case "Attempt":
if let attempt = mapRecordToAttempt(record, id: uuid) {
if let existingIndex = dataManager.attempts.firstIndex(where: { $0.id == uuid }) {
let existing = dataManager.attempts[existingIndex]
if attempt.updatedAt >= existing.updatedAt {
dataManager.attempts[existingIndex] = attempt
dataManager.saveAttempts()
}
} else {
dataManager.attempts.append(attempt)
dataManager.saveAttempts()
}
}
default:
break
}
}
}
}
// MARK: - Push (Upload to CloudKit)
private func pushChanges(dataManager: ClimbingDataManager) async throws {
let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date ?? Date.distantPast
let modifiedGyms = dataManager.gyms.filter { $0.updatedAt > lastSync }
let modifiedProblems = dataManager.problems.filter { $0.updatedAt > lastSync }
let modifiedSessions = dataManager.sessions.filter { $0.updatedAt > lastSync }
let modifiedAttempts = dataManager.attempts.filter { $0.createdAt > lastSync }
let deletedItems = dataManager.getDeletedItems().filter { item in
let dateFormatter = ISO8601DateFormatter()
if let date = dateFormatter.date(from: item.deletedAt) {
return date > lastSync
}
return false
}
if modifiedGyms.isEmpty && modifiedProblems.isEmpty && modifiedSessions.isEmpty && modifiedAttempts.isEmpty && deletedItems.isEmpty {
AppLogger.info("No local changes to push", tag: logTag)
return
}
var recordsToSave: [CKRecord] = []
var recordIDsToDelete: [CKRecord.ID] = []
for item in deletedItems {
recordIDsToDelete.append(CKRecord.ID(recordName: item.id, zoneID: zoneID))
}
for gym in modifiedGyms {
recordsToSave.append(createRecord(from: gym))
}
for problem in modifiedProblems {
recordsToSave.append(createRecord(from: problem))
}
for session in modifiedSessions {
recordsToSave.append(createRecord(from: session))
}
for attempt in modifiedAttempts {
recordsToSave.append(createRecord(from: attempt))
}
guard !recordsToSave.isEmpty || !recordIDsToDelete.isEmpty else { return }
AppLogger.info("Pushing to iCloud: \(recordsToSave.count) saves, \(recordIDsToDelete.count) deletions", tag: logTag)
let operation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
operation.savePolicy = .changedKeys // Merges changes if possible, simpler than handling tags manually
operation.isAtomic = true // Transactional
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
operation.modifyRecordsResultBlock = { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
database.add(operation)
}
}
// MARK: - Mappers (To CKRecord)
private func createRecord(from gym: Gym) -> CKRecord {
let recordID = CKRecord.ID(recordName: gym.id.uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Gym", recordID: recordID)
record["name"] = gym.name
record["location"] = gym.location
record["notes"] = gym.notes
record["createdAt"] = gym.createdAt
record["updatedAt"] = gym.updatedAt
record["supportedClimbTypes"] = encode(gym.supportedClimbTypes)
record["difficultySystems"] = encode(gym.difficultySystems)
record["customDifficultyGrades"] = encode(gym.customDifficultyGrades)
return record
}
private func createRecord(from problem: Problem) -> CKRecord {
let recordID = CKRecord.ID(recordName: problem.id.uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Problem", recordID: recordID)
record["gymId"] = problem.gymId.uuidString
record["name"] = problem.name
record["description"] = problem.description
record["climbType"] = problem.climbType.rawValue
record["difficulty"] = encode(problem.difficulty)
record["tags"] = encode(problem.tags)
record["location"] = problem.location
record["isActive"] = problem.isActive ? 1 : 0
record["dateSet"] = problem.dateSet
record["notes"] = problem.notes
record["createdAt"] = problem.createdAt
record["updatedAt"] = problem.updatedAt
var assets: [CKAsset] = []
for path in problem.imagePaths {
let fullPath = ImageManager.shared.getFullPath(from: path)
let fileURL = URL(fileURLWithPath: fullPath)
if FileManager.default.fileExists(atPath: fullPath) {
assets.append(CKAsset(fileURL: fileURL))
}
}
if !assets.isEmpty {
record["images"] = assets
}
return record
}
private func createRecord(from session: ClimbSession) -> CKRecord {
let recordID = CKRecord.ID(recordName: session.id.uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "ClimbSession", recordID: recordID)
record["gymId"] = session.gymId.uuidString
record["date"] = session.date
record["startTime"] = session.startTime
record["endTime"] = session.endTime
record["duration"] = session.duration
record["status"] = session.status.rawValue
record["notes"] = session.notes
record["createdAt"] = session.createdAt
record["updatedAt"] = session.updatedAt
return record
}
private func createRecord(from attempt: Attempt) -> CKRecord {
let recordID = CKRecord.ID(recordName: attempt.id.uuidString, zoneID: zoneID)
let record = CKRecord(recordType: "Attempt", recordID: recordID)
record["sessionId"] = attempt.sessionId.uuidString
record["problemId"] = attempt.problemId.uuidString
record["result"] = attempt.result.rawValue
record["highestHold"] = attempt.highestHold
record["notes"] = attempt.notes
record["duration"] = attempt.duration
record["restTime"] = attempt.restTime
record["timestamp"] = attempt.timestamp
record["createdAt"] = attempt.createdAt
record["updatedAt"] = attempt.updatedAt
return record
}
// MARK: - Mappers (From CKRecord)
private func mapRecordToGym(_ record: CKRecord, id: UUID) -> Gym? {
guard let name = record["name"] as? String,
let supportedClimbTypesData = record["supportedClimbTypes"] as? String,
let difficultySystemsData = record["difficultySystems"] as? String,
let createdAt = record["createdAt"] as? Date,
let updatedAt = record["updatedAt"] as? Date
else { return nil }
let location = record["location"] as? String
let notes = record["notes"] as? String
let customGradesData = record["customDifficultyGrades"] as? String ?? "[]"
let supportedClimbTypes: [ClimbType] = decode(supportedClimbTypesData) ?? []
let difficultySystems: [DifficultySystem] = decode(difficultySystemsData) ?? []
let customDifficultyGrades: [String] = decode(customGradesData) ?? []
return Gym.fromImport(
id: id,
name: name,
location: location,
supportedClimbTypes: supportedClimbTypes,
difficultySystems: difficultySystems,
customDifficultyGrades: customDifficultyGrades,
notes: notes,
createdAt: createdAt,
updatedAt: updatedAt
)
}
private func mapRecordToProblem(_ record: CKRecord, id: UUID) -> Problem? {
guard let gymIdString = record["gymId"] as? String,
let gymId = UUID(uuidString: gymIdString),
let climbTypeRaw = record["climbType"] as? String,
let climbType = ClimbType(rawValue: climbTypeRaw),
let difficultyData = record["difficulty"] as? String,
let difficulty: DifficultyGrade = decode(difficultyData),
let createdAt = record["createdAt"] as? Date,
let updatedAt = record["updatedAt"] as? Date
else { return nil }
let name = record["name"] as? String
let description = record["description"] as? String
let tagsData = record["tags"] as? String ?? "[]"
let tags: [String] = decode(tagsData) ?? []
let location = record["location"] as? String
let isActive = (record["isActive"] as? Int ?? 1) == 1
let dateSet = record["dateSet"] as? Date
let notes = record["notes"] as? String
var imagePaths: [String] = []
if let assets = record["images"] as? [CKAsset] {
for (index, asset) in assets.enumerated() {
guard let fileURL = asset.fileURL else { continue }
let filename = ImageNamingUtils.generateImageFilename(problemId: id.uuidString, imageIndex: index)
if let data = try? Data(contentsOf: fileURL) {
_ = try? ImageManager.shared.saveImportedImage(data, filename: filename)
imagePaths.append(filename)
}
}
}
return Problem.fromImport(
id: id,
gymId: gymId,
name: name,
description: description,
climbType: climbType,
difficulty: difficulty,
tags: tags,
location: location,
imagePaths: imagePaths,
isActive: isActive,
dateSet: dateSet,
notes: notes,
createdAt: createdAt,
updatedAt: updatedAt
)
}
private func mapRecordToSession(_ record: CKRecord, id: UUID) -> ClimbSession? {
guard let gymIdString = record["gymId"] as? String,
let gymId = UUID(uuidString: gymIdString),
let date = record["date"] as? Date,
let statusRaw = record["status"] as? String,
let status = SessionStatus(rawValue: statusRaw),
let createdAt = record["createdAt"] as? Date,
let updatedAt = record["updatedAt"] as? Date
else { return nil }
let startTime = record["startTime"] as? Date
let endTime = record["endTime"] as? Date
let duration = record["duration"] as? Int
let notes = record["notes"] as? String
return ClimbSession.fromImport(
id: id,
gymId: gymId,
date: date,
startTime: startTime,
endTime: endTime,
duration: duration,
status: status,
notes: notes,
createdAt: createdAt,
updatedAt: updatedAt
)
}
private func mapRecordToAttempt(_ record: CKRecord, id: UUID) -> Attempt? {
guard let sessionIdString = record["sessionId"] as? String,
let sessionId = UUID(uuidString: sessionIdString),
let problemIdString = record["problemId"] as? String,
let problemId = UUID(uuidString: problemIdString),
let resultRaw = record["result"] as? String,
let result = AttemptResult(rawValue: resultRaw),
let timestamp = record["timestamp"] as? Date,
let createdAt = record["createdAt"] as? Date,
let updatedAt = record["updatedAt"] as? Date
else { return nil }
let highestHold = record["highestHold"] as? String
let notes = record["notes"] as? String
let duration = record["duration"] as? Int
let restTime = record["restTime"] as? Int
return Attempt.fromImport(
id: id,
sessionId: sessionId,
problemId: problemId,
result: result,
highestHold: highestHold,
notes: notes,
duration: duration,
restTime: restTime,
timestamp: timestamp,
createdAt: createdAt,
updatedAt: updatedAt
)
}
// MARK: - Helper Coding
private func encode<T: Encodable>(_ value: T) -> String {
guard let data = try? JSONEncoder().encode(value) else { return "" }
return String(data: data, encoding: .utf8) ?? ""
}
private func decode<T: Decodable>(_ json: String) -> T? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import HealthKit import HealthKit
import MusicKit import MusicKit
import SwiftUI import SwiftUI
import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
enum SheetType { enum SheetType {
@@ -28,6 +29,8 @@ struct SettingsView: View {
AppearanceSection() AppearanceSection()
AppIconSection()
DataManagementSection( DataManagementSection(
activeSheet: $activeSheet activeSheet: $activeSheet
) )
@@ -168,6 +171,61 @@ struct AppearanceSection: View {
} }
} }
struct AppIconSection: View {
@State private var currentIcon: String? = UIApplication.shared.alternateIconName
var body: some View {
Section("App Icon") {
Button(action: {
setIcon(nil)
}) {
HStack {
Image(systemName: "triangle.fill")
Text("Peaks")
.foregroundColor(.primary)
Spacer()
if currentIcon == nil {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
Button(action: {
setIcon("Balls")
}) {
HStack {
Image(systemName: "circle.fill")
Text("Balls")
.foregroundColor(.primary)
Spacer()
if currentIcon == "Balls" {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
.onAppear {
currentIcon = UIApplication.shared.alternateIconName
}
}
private func setIcon(_ name: String?) {
guard UIApplication.shared.alternateIconName != name else { return }
UIApplication.shared.setAlternateIconName(name) { error in
if let error = error {
print("Error setting icon: \(error.localizedDescription)")
} else {
DispatchQueue.main.async {
currentIcon = name
}
}
}
}
}
struct DataManagementSection: View { struct DataManagementSection: View {
@EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager @EnvironmentObject var themeManager: ThemeManager
@@ -555,7 +613,7 @@ struct SyncSection: View {
: .red : .red
) )
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Sync Server") Text(syncService.providerType == .iCloud ? "iCloud Sync" : "Sync Server")
.font(.headline) .font(.headline)
Text( Text(
syncService.isConnected syncService.isConnected
@@ -570,14 +628,14 @@ struct SyncSection: View {
Spacer() Spacer()
} }
// Configure Server // Configure Sync
Button(action: { Button(action: {
activeSheet = .syncSettings activeSheet = .syncSettings
}) { }) {
HStack { HStack {
Image(systemName: "gear") Image(systemName: "gear")
.foregroundColor(themeManager.accentColor) .foregroundColor(themeManager.accentColor)
Text("Configure Server") Text("Configure Sync")
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
@@ -698,10 +756,22 @@ struct SyncSettingsView: View {
@State private var isTesting = false @State private var isTesting = false
@State private var showingTestResult = false @State private var showingTestResult = false
@State private var testResultMessage = "" @State private var testResultMessage = ""
@State private var selectedProvider: SyncProviderType = .server
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section {
Picker("Provider", selection: $selectedProvider) {
ForEach(SyncProviderType.allCases) { type in
Text(type.displayName).tag(type)
}
}
} header: {
Text("Sync Provider")
}
if selectedProvider == .server {
Section { Section {
TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080")) TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
.keyboardType(.URL) .keyboardType(.URL)
@@ -718,7 +788,9 @@ struct SyncSettingsView: View {
"Enter your sync server URL and authentication token. You must test the connection before syncing is available." "Enter your sync server URL and authentication token. You must test the connection before syncing is available."
) )
} }
}
if selectedProvider != .none {
Section { Section {
Button(action: { Button(action: {
testConnection() testConnection()
@@ -734,7 +806,7 @@ struct SyncSettingsView: View {
.foregroundColor(themeManager.accentColor) .foregroundColor(themeManager.accentColor)
Text("Test Connection") Text("Test Connection")
Spacer() Spacer()
if syncService.isConnected { if syncService.isConnected && syncService.providerType == selectedProvider {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
} }
@@ -743,18 +815,19 @@ struct SyncSettingsView: View {
} }
.disabled( .disabled(
isTesting isTesting
|| serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (selectedProvider == .server && (serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
) )
.foregroundColor(.primary) .foregroundColor(.primary)
} header: { } header: {
Text("Connection") Text("Connection")
} footer: { } footer: {
Text("Test the connection to verify your server settings before saving.") Text("Test the connection to verify your settings before saving.")
}
} }
Section { Section {
Button("Disconnect from Server") { Button("Disconnect") {
showingDisconnectAlert = true showingDisconnectAlert = true
} }
.foregroundColor(.orange) .foregroundColor(.orange)
@@ -785,18 +858,17 @@ struct SyncSettingsView: View {
let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines) let newToken = authToken.trimmingCharacters(in: .whitespacesAndNewlines)
// Mark as disconnected if settings changed // Mark as disconnected if settings changed
if newURL != syncService.serverURL || newToken != syncService.authToken { if selectedProvider == .server && (newURL != syncService.serverURL || newToken != syncService.authToken) {
syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected")
} else if selectedProvider != syncService.providerType {
syncService.isConnected = false syncService.isConnected = false
UserDefaults.standard.set(false, forKey: "sync_is_connected") UserDefaults.standard.set(false, forKey: "sync_is_connected")
} }
syncService.serverURL = newURL syncService.serverURL = newURL
syncService.authToken = newToken syncService.authToken = newToken
syncService.providerType = selectedProvider
// Ensure provider type is set to server
if syncService.providerType != .server {
syncService.providerType = .server
}
dismiss() dismiss()
} }
@@ -807,6 +879,7 @@ struct SyncSettingsView: View {
.onAppear { .onAppear {
serverURL = syncService.serverURL serverURL = syncService.serverURL
authToken = syncService.authToken authToken = syncService.authToken
selectedProvider = syncService.providerType
} }
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
@@ -835,17 +908,19 @@ struct SyncSettingsView: View {
// Store original values in case test fails // Store original values in case test fails
let originalURL = syncService.serverURL let originalURL = syncService.serverURL
let originalToken = syncService.authToken let originalToken = syncService.authToken
let originalProvider = syncService.providerType
Task { @MainActor in Task { @MainActor in
do { do {
// Ensure we are using the server provider // Switch to selected provider
if syncService.providerType != .server { if syncService.providerType != selectedProvider {
syncService.providerType = .server syncService.providerType = selectedProvider
} }
// Temporarily set the values for testing if selectedProvider == .server {
syncService.serverURL = testURL syncService.serverURL = testURL
syncService.authToken = testToken syncService.authToken = testToken
}
// Explicitly sync UserDefaults to ensure immediate availability // Explicitly sync UserDefaults to ensure immediate availability
UserDefaults.standard.synchronize() UserDefaults.standard.synchronize()
@@ -858,8 +933,11 @@ struct SyncSettingsView: View {
showingTestResult = true showingTestResult = true
} catch { } catch {
// Restore original values if test failed // Restore original values if test failed
syncService.providerType = originalProvider
if originalProvider == .server {
syncService.serverURL = originalURL syncService.serverURL = originalURL
syncService.authToken = originalToken syncService.authToken = originalToken
}
isTesting = false isTesting = false
testResultMessage = "Connection failed: \(error.localizedDescription)" testResultMessage = "Connection failed: \(error.localizedDescription)"

View File

@@ -1,6 +1,6 @@
# Ascently for iOS # Ascently for iOS
The native iOS and widget client for Ascently, built with Swift and SwiftUI. The native iOS app and widget for Ascently, built with Swift and SwiftUI.
## Project Structure ## Project Structure