Compare commits

...

27 Commits

Author SHA1 Message Date
77f7092287 New build... apple pls 2026-03-03 17:11:12 -07:00
ed25cf7ecd Bump 2026-02-02 10:00:47 -07:00
255f85c2df Ok fixed more nonsense 2026-02-02 10:00:07 -07:00
a3d47d29c5 OOps 2026-02-02 09:47:17 -07:00
b94b823986 Fixed add/edit Attempts View 2026-02-02 09:41:28 -07:00
58d84af29b Fixed add/edit problems 2026-02-02 09:36:02 -07:00
12f9463e8c Android 2.5.2 2026-02-02 00:07:46 -07:00
aa3ddfc7cb iOS 2.7.3 - Removed Music Integration & Refactoring 2026-02-02 00:00:03 -07:00
25688b0615 O p t i m i z e 2026-02-01 23:52:50 -07:00
3874703fcb Remove music
All checks were successful
Ascently - Docs Deploy / build-and-push (push) Successful in 3m57s
2026-02-01 17:55:51 -07:00
aa08892e75 Update .gitattributes 2026-01-16 17:52:56 +00:00
4da10912fc iOS 2.7.2 - Icons! 2026-01-14 14:17:36 -07:00
94d2f9d951 Added pan. Working on gay. 2026-01-14 09:16:32 -07:00
6e679236c8 2.7.2 2026-01-13 23:57:07 -07:00
06fe659478 Small modifications 2026-01-13 23:55:47 -07:00
390b4bf499 Pride Icons 2026-01-13 23:48:20 -07:00
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
110 changed files with 2808 additions and 1926 deletions

2
.gitattributes vendored
View File

@@ -75,3 +75,5 @@ pnpm-lock.yaml text -diff
# Documentation
LICENSE text eol=lf
README.md text eol=lf
*.pxd linguist-vendored

View File

@@ -1,5 +1,7 @@
# 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_
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.

View File

@@ -18,8 +18,8 @@ android {
applicationId = "com.atridad.ascently"
minSdk = 31
targetSdk = 36
versionCode = 51
versionName = "2.5.1"
versionCode = 52
versionName = "2.5.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -10,19 +10,19 @@ androidxTestExt = "1.3.0"
androidxTestRunner = "1.7.0"
androidxTestRules = "1.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.2"
composeBom = "2025.12.01"
activityCompose = "1.12.3"
composeBom = "2026.01.01"
room = "2.8.4"
navigation = "2.9.6"
navigation = "2.9.7"
viewmodel = "2.10.0"
kotlinxSerialization = "1.9.0"
kotlinxSerialization = "1.10.0"
kotlinxCoroutines = "1.10.2"
coil = "2.7.0"
ksp = "2.2.20-2.0.3"
exifinterface = "1.4.2"
healthConnect = "1.1.0"
detekt = "1.23.8"
spotless = "8.1.0"
spotless = "8.2.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

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

View File

@@ -25,9 +25,9 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^9.5.1",
"@astrojs/starlight": "^0.37.1",
"astro": "^5.16.5",
"@astrojs/node": "^9.5.2",
"@astrojs/starlight": "^0.37.5",
"astro": "^5.17.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5"
},

789
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 42 KiB

View File

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

Before

Width:  |  Height:  |  Size: 244 B

View File

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

Before

Width:  |  Height:  |  Size: 259 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

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

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

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

View File

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

View File

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

View File

@@ -460,13 +460,13 @@
D24C19742E75002A0045894C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = Balls;
ASSETCATALOG_COMPILER_APPICON_NAME = Icon;
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "Balls Pride Trans Lesbian Enby Bi Pan Gay Loss";
ASSETCATALOG_COMPILER_APPICON_NAME = Peaks;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -491,7 +491,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.7.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -512,13 +512,13 @@
D24C19752E75002A0045894C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = Balls;
ASSETCATALOG_COMPILER_APPICON_NAME = Icon;
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "Balls Pride Trans Lesbian Enby Bi Pan Gay Loss";
ASSETCATALOG_COMPILER_APPICON_NAME = Peaks;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
ENABLE_PREVIEWS = YES;
@@ -543,7 +543,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.6;
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.7.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -610,7 +610,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -622,7 +622,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.7.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -641,7 +641,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 44;
CURRENT_PROJECT_VERSION = 53;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = SessionStatusLive/Info.plist;
@@ -653,7 +653,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.7.3;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

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

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

View File

Before

Width:  |  Height:  |  Size: 64 KiB

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

View File

Before

Width:  |  Height:  |  Size: 66 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -0,0 +1,56 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscentlyBiBlue.png",
"name" : "AscentlyBiBlue",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
103.3033720318974,
89.61597895201449
]
}
},
{
"image-name" : "AscentlyBiPurple.png",
"name" : "AscentlyBiPurple",
"position" : {
"scale" : 0.6,
"translation-in-points" : [
52.22951746701784,
44.45130454558263
]
}
},
{
"image-name" : "AscentlyBiPink.png",
"name" : "AscentlyBiPink",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -0,0 +1,52 @@
import PhotosUI
import SwiftUI
struct ImagePicker: UIViewControllerRepresentable {
@Binding var selectedImages: [Data]
let sourceType: UIImagePickerController.SourceType
let selectionLimit: Int
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = sourceType
picker.allowsEditing = false
if sourceType == .photoLibrary {
picker.modalPresentationStyle = .automatic
}
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// No-op
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let image = info[.originalImage] as? UIImage,
let data = image.jpegData(compressionQuality: 0.8) {
parent.selectedImages.append(data)
}
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.dismiss()
}
}
}

View File

@@ -44,7 +44,6 @@ struct ContentView: View {
.tag(4)
}
.environmentObject(dataManager)
.environmentObject(MusicService.shared)
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
// Add slight delay to ensure app is fully loaded

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -0,0 +1,73 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"blend-mode" : "normal",
"glass" : true,
"hidden" : false,
"image-name" : "AscentlyEnbyYellow.png",
"name" : "AscentlyEnbyYellow",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
2.17046922693007,
57.25015517558532
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyEnbyWhite.png",
"name" : "AscentlyEnbyWhite",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
3.5797767879914275,
39.45555690497569
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyEnbyPurple.png",
"name" : "AscentlyEnbyPurple",
"position" : {
"scale" : 0.55,
"translation-in-points" : [
1.2888098929849576,
20.660262557762508
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyEnbyBlack.png",
"name" : "AscentlyEnbyBlack",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -0,0 +1,100 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscentlyGayDarkBlue.png",
"name" : "AscentlyGayDarkBlue",
"position" : {
"scale" : 0.1,
"translation-in-points" : [
310.3109038708518,
268.51915188743055
]
}
},
{
"image-name" : "AscentlyGayBlue.png",
"name" : "AscentlyGayBlue",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
256.74709727330895,
223.14027483654624
]
}
},
{
"image-name" : "AscentlyGayLightBlue.png",
"name" : "AscentlyGayLightBlue",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
205.90293882922413,
178.6755090412264
]
}
},
{
"image-name" : "AscentlyGayWhite.png",
"name" : "AscentlyGayWhite",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
155.16481082482767,
134.12218495264162
]
}
},
{
"image-name" : "AscentlyGayLightGreen.png",
"name" : "AscentlyGayLightGreen",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
103.05243597030815,
88.66555310378573
]
}
},
{
"image-name" : "AscentlyGayGreen.png",
"name" : "AscentlyGayGreen",
"position" : {
"scale" : 0.6,
"translation-in-points" : [
52.23658982195185,
44.839904477203575
]
}
},
{
"image-name" : "AscentlyGayDarkGreen.png",
"name" : "AscentlyGayDarkGreen",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -0,0 +1,95 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"blend-mode" : "normal",
"image-name" : "AscentlyLesbianDarkRose.png",
"name" : "AscentlyLesbianDarkRose",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
-262.1182952725325,
223.6381362543554
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyLesbianDustyPink.png",
"name" : "AscentlyLesbianDustyPink",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
-209.7587555742847,
179.5733935018924
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyLesbianPink.png",
"name" : "AscentlyLesbianPink",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
-158.0346362340688,
135.83476297272654
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyLesbianLightOrange.png",
"name" : "AscentlyLesbianLightOrange",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
-106.63948555089338,
90.22853679410112
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyLesbianOrange.png",
"name" : "AscentlyLesbianOrange",
"position" : {
"scale" : 0.6,
"translation-in-points" : [
-53.734375,
45.125
]
}
},
{
"blend-mode" : "normal",
"image-name" : "AscentlyLesbianDarkOrange.png",
"name" : "AscentlyLesbianDarkOrange",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,100 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscetlyTriangle1.png",
"name" : "AscetlyTriangle1",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
-233.3046875,
-226.703125
]
}
},
{
"image-name" : "AscetlyTriangle2.png",
"name" : "AscetlyTriangle2",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
229.078125,
-225.5234375
]
}
},
{
"image-name" : "AscetlyTriangle1.png",
"name" : "AscetlyTriangle1",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
109.82172229457004,
-188.45752746110693
]
}
},
{
"image-name" : "AscetlyTriangle2.png",
"name" : "AscetlyTriangle2",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
-175.51648415989322,
187.17564929470825
]
}
},
{
"image-name" : "AscetlyTriangle1.png",
"name" : "AscetlyTriangle1",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
-282.9895849116331,
187.65385356072886
]
}
},
{
"image-name" : "AscetlyTriangle2 2.png",
"name" : "AscetlyTriangle2 2",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
279.27255440242436,
292.15535460179336
]
}
},
{
"image-name" : "AscetlyTriangle1.png",
"name" : "AscetlyTriangle1",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
196.03206587241937,
183.14521924511502
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,56 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscentlyPanBlue.png",
"name" : "AscentlyPanBlue",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
104.70996037782118,
90.85744041806392
]
}
},
{
"image-name" : "AscentlyPanYellow.png",
"name" : "AscentlyPanYellow",
"position" : {
"scale" : 0.6,
"translation-in-points" : [
52.60885224890252,
46.29197733709991
]
}
},
{
"image-name" : "AscentlyPanPink.png",
"name" : "AscentlyPanPink",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,90 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"glass" : true,
"image-name" : "AscentlyPridePurple.png",
"name" : "AscentlyPridePurple",
"position" : {
"scale" : 0.2,
"translation-in-points" : [
261.33312440800023,
225.98735919808558
]
}
},
{
"image-name" : "AscentlyPrideBlue.png",
"name" : "AscentlyPrideBlue",
"position" : {
"scale" : 0.3,
"translation-in-points" : [
208.7958513297372,
182.17013891753604
]
}
},
{
"image-name" : "AscentlyPrideGreen.png",
"name" : "AscentlyPrideGreen",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
159.12124296418187,
138.63843261157083
]
}
},
{
"image-name" : "AscentlyPrideYellow.png",
"name" : "AscentlyPrideYellow",
"position" : {
"scale" : 0.5,
"translation-in-points" : [
106.78092927846878,
91.24931820626618
]
}
},
{
"image-name" : "AscentlyPrideOrange.png",
"name" : "AscentlyPrideOrange",
"position" : {
"scale" : 0.6,
"translation-in-points" : [
50.9140625,
44.484375
]
}
},
{
"image-name" : "AscentlyPrideRed.png",
"name" : "AscentlyPrideRed",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -1,192 +0,0 @@
import AVFoundation
import Combine
import MusicKit
import SwiftUI
@MainActor
class MusicService: ObservableObject {
static let shared = MusicService()
@Published var isAuthorized = false
@Published var playlists: MusicItemCollection<Playlist> = []
@Published var selectedPlaylistId: String? {
didSet {
UserDefaults.standard.set(selectedPlaylistId, forKey: "ascently_selected_playlist_id")
}
}
@Published var isMusicEnabled: Bool {
didSet {
UserDefaults.standard.set(isMusicEnabled, forKey: "ascently_music_enabled")
if !isMusicEnabled {
// Genuinely unsure what I want to do with this but we should account for it at some point
}
}
}
@Published var isAutoPlayEnabled: Bool {
didSet {
UserDefaults.standard.set(isAutoPlayEnabled, forKey: "ascently_music_autoplay_enabled")
}
}
@Published var isAutoStopEnabled: Bool {
didSet {
UserDefaults.standard.set(isAutoStopEnabled, forKey: "ascently_music_autostop_enabled")
}
}
@Published var isPlaying = false
private var cancellables = Set<AnyCancellable>()
private var hasStartedSessionPlayback = false
private var currentPlaylistTrackIds: Set<MusicItemID> = []
private init() {
self.selectedPlaylistId = UserDefaults.standard.string(forKey: "ascently_selected_playlist_id")
self.isMusicEnabled = UserDefaults.standard.bool(forKey: "ascently_music_enabled")
self.isAutoPlayEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autoplay_enabled")
self.isAutoStopEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autostop_enabled")
if isMusicEnabled {
Task {
await checkAuthorizationStatus()
}
}
setupObservers()
}
private func setupObservers() {
SystemMusicPlayer.shared.state.objectWillChange
.sink { [weak self] _ in
self?.updatePlaybackStatus()
}
.store(in: &cancellables)
SystemMusicPlayer.shared.queue.objectWillChange
.sink { [weak self] _ in
self?.checkQueueConsistency()
}
.store(in: &cancellables)
}
private func updatePlaybackStatus() {
isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
}
private func checkQueueConsistency() {
guard hasStartedSessionPlayback else { return }
if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry,
let item = currentEntry.item {
if !currentPlaylistTrackIds.isEmpty && !currentPlaylistTrackIds.contains(item.id) {
hasStartedSessionPlayback = false
}
}
}
func toggleMusicEnabled(_ enabled: Bool) {
isMusicEnabled = enabled
if enabled {
Task {
await checkAuthorizationStatus()
}
}
}
func checkAuthorizationStatus() async {
let status = await MusicAuthorization.request()
self.isAuthorized = status == .authorized
if isAuthorized {
await fetchPlaylists()
}
}
func fetchPlaylists() async {
guard isAuthorized else { return }
do {
var request = MusicLibraryRequest<Playlist>()
request.sort(by: \.name, ascending: true)
let response = try await request.response()
self.playlists = response.items
} catch {
print("Error fetching playlists: \(error)")
}
}
func playSelectedPlaylistIfHeadphonesConnected() {
guard isMusicEnabled, isAutoPlayEnabled, let playlistId = selectedPlaylistId else { return }
if isHeadphonesConnected() {
playPlaylist(id: playlistId)
}
}
func resetSessionPlaybackState() {
hasStartedSessionPlayback = false
currentPlaylistTrackIds.removeAll()
}
func playPlaylist(id: String) {
print("Attempting to play playlist \(id)")
Task {
do {
if playlists.isEmpty {
await fetchPlaylists()
}
var targetPlaylist: Playlist?
if let playlist = playlists.first(where: { $0.id.rawValue == id }) {
targetPlaylist = playlist
} else {
var request = MusicLibraryRequest<Playlist>()
request.filter(matching: \.id, equalTo: MusicItemID(id))
let response = try await request.response()
targetPlaylist = response.items.first
}
if let playlist = targetPlaylist {
let detailedPlaylist = try await playlist.with([.tracks])
if let tracks = detailedPlaylist.tracks {
self.currentPlaylistTrackIds = Set(tracks.map { $0.id })
}
SystemMusicPlayer.shared.queue = [playlist]
try await SystemMusicPlayer.shared.play()
hasStartedSessionPlayback = true
}
} catch {
print("Error playing playlist: \(error)")
}
}
}
func stopPlaybackIfEnabled() {
guard isMusicEnabled, isAutoStopEnabled else { return }
SystemMusicPlayer.shared.stop()
}
func togglePlayback() {
Task {
if isPlaying {
SystemMusicPlayer.shared.pause()
} else {
if let playlistId = selectedPlaylistId, !hasStartedSessionPlayback {
playPlaylist(id: playlistId)
} else {
try? await SystemMusicPlayer.shared.play()
}
}
}
}
private func isHeadphonesConnected() -> Bool {
let route = AVAudioSession.sharedInstance().currentRoute
return route.outputs.contains { port in
port.portType == .headphones ||
port.portType == .bluetoothA2DP ||
port.portType == .bluetoothLE ||
port.portType == .bluetoothHFP ||
port.portType == .usbAudio ||
port.portType == .airPlay
}
}
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -0,0 +1,78 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"image-name" : "AscentlyTransBlue 2.png",
"name" : "AscentlyTransBlue 2",
"position" : {
"scale" : 0.1,
"translation-in-points" : [
2.34375,
68.515625
]
}
},
{
"image-name" : "AscentlyTransPink 2.png",
"name" : "AscentlyTransPink 2",
"position" : {
"scale" : 0.25,
"translation-in-points" : [
0.84375,
49.453125
]
}
},
{
"image-name" : "AscentlyTransWhite.png",
"name" : "AscentlyTransWhite",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
1.953125,
34.265625
]
}
},
{
"image-name" : "AscentlyTransPink.png",
"name" : "AscentlyTransPink",
"position" : {
"scale" : 0.55,
"translation-in-points" : [
-0.0546875,
17.4921875
]
}
},
{
"image-name" : "AscentlyTransBlue.png",
"name" : "AscentlyTransBlue",
"position" : {
"scale" : 0.7,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -0,0 +1,99 @@
import Foundation
import SwiftUI
enum AppSettings {
enum Keys {
static let accentColor = "accentColorData"
static let syncServerURL = "sync_server_url"
static let syncAuthToken = "sync_auth_token"
static let lastSyncTime = "last_sync_time"
static let syncIsConnected = "is_connected"
static let autoSyncEnabled = "auto_sync_enabled"
static let offlineMode = "offline_mode"
static let syncProviderType = "sync_provider_type"
static let healthKitEnabled = "healthkit_enabled"
static let autoBackupEnabled = "auto_backup_enabled"
static let lastBackupTime = "last_backup_time"
static let defaultClimbType = "default_climb_type"
static let defaultDifficultySystem = "default_difficulty_system"
}
static func set<T>(_ value: T, forKey key: String) {
UserDefaults.standard.set(value, forKey: key)
}
static func get<T>(_ type: T.Type, forKey key: String, defaultValue: T) -> T {
guard let value = UserDefaults.standard.object(forKey: key) as? T else {
return defaultValue
}
return value
}
static func remove(forKey key: String) {
UserDefaults.standard.removeObject(forKey: key)
}
static func getString(forKey key: String, defaultValue: String = "") -> String {
return UserDefaults.standard.string(forKey: key) ?? defaultValue
}
static func setString(_ value: String, forKey key: String) {
UserDefaults.standard.set(value, forKey: key)
}
static func getBool(forKey key: String, defaultValue: Bool = false) -> Bool {
return UserDefaults.standard.bool(forKey: key)
}
static func setBool(_ value: Bool, forKey key: String) {
UserDefaults.standard.set(value, forKey: key)
}
static func getDate(forKey key: String) -> Date? {
return UserDefaults.standard.object(forKey: key) as? Date
}
static func setDate(_ value: Date?, forKey key: String) {
if let date = value {
UserDefaults.standard.set(date, forKey: key)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
}
}
enum AppError: LocalizedError {
case validationFailed(String)
case dataCorruption(String)
case syncFailed(String)
case networkError(String)
var errorDescription: String? {
switch self {
case .validationFailed(let message):
return "Validation failed: \(message)"
case .dataCorruption(let message):
return "Data corruption: \(message)"
case .syncFailed(let message):
return "Sync failed: \(message)"
case .networkError(let message):
return "Network error: \(message)"
}
}
}
extension View {
func errorMessage(_ error: AppError?) -> some View {
Group {
if let error = error {
Text(error.localizedDescription)
.foregroundColor(.red)
.font(.caption)
.padding(.horizontal)
.padding(.vertical, 4)
.background(Color.red.opacity(0.1))
.cornerRadius(4)
}
}
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
struct DataHelper {
static func trimString(_ string: String) -> String {
string.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func isEmptyOrNil(_ string: String) -> String? {
let trimmed = trimString(string)
return trimmed.isEmpty ? nil : trimmed
}
static func splitTags(_ tags: String) -> [String] {
return tags.split(separator: ",")
.compactMap { trimString(String($0)) }
.filter { !$0.isEmpty }
}
}

Some files were not shown because too many files have changed in this diff Show More