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