Compare commits

..

20 Commits

Author SHA1 Message Date
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
107 changed files with 2619 additions and 1453 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -14,8 +14,8 @@ export default defineConfig({
description:
"An offline-first FOSS climb tracking app with an optional sync server.",
logo: {
light: "./src/assets/logo.svg",
dark: "./src/assets/logo-dark.svg",
light: "./src/assets/logo.png",
dark: "./src/assets/logo.png",
},
favicon: "/favicon.png",
social: [
@@ -55,4 +55,14 @@ export default defineConfig({
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 = 50;
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 = 50;
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 = 50;
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 = 50;
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

@@ -10,7 +10,7 @@ enum SyncProviderType: String, CaseIterable, Identifiable {
switch self {
case .none: return "None"
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 isConfigured: Bool { get }
var isConnected: Bool { get }
var lastSyncTime: Date? { get }
func sync(dataManager: ClimbingDataManager) async throws
func testConnection() async throws

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

View File

@@ -38,7 +38,6 @@ class ClimbingDataManager: ObservableObject {
let syncService = SyncService()
let healthKitService = HealthKitService.shared
let musicService = MusicService.shared
@Published var isSyncing = false
private enum Keys {
@@ -306,8 +305,6 @@ class ClimbingDataManager: ObservableObject {
gyms.append(gym)
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -318,8 +315,6 @@ class ClimbingDataManager: ObservableObject {
gyms[index] = gym
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -344,8 +339,6 @@ class ClimbingDataManager: ObservableObject {
trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
saveGyms()
DataStateManager.shared.updateDataState()
successMessage = "Gym deleted successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -359,8 +352,6 @@ class ClimbingDataManager: ObservableObject {
problems.append(problem)
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem added successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -371,8 +362,6 @@ class ClimbingDataManager: ObservableObject {
problems[index] = problem
saveProblems()
DataStateManager.shared.updateDataState()
successMessage = "Problem updated successfully"
clearMessageAfterDelay()
// Trigger auto-sync if enabled
syncService.triggerAutoSync(dataManager: self)
@@ -437,8 +426,7 @@ class ClimbingDataManager: ObservableObject {
gymName: gym.name)
}
musicService.resetSessionPlaybackState()
musicService.playSelectedPlaylistIfHeadphonesConnected()
if healthKitService.isEnabled {
do {
@@ -480,10 +468,7 @@ class ClimbingDataManager: ObservableObject {
await LiveActivityManager.shared.endLiveActivity()
if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") {
musicService.stopPlaybackIfEnabled()
}
musicService.stopPlaybackIfEnabled()
if healthKitService.isEnabled {
do {

View File

@@ -1,9 +1,9 @@
import PhotosUI
import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
struct AddEditProblemView: View {
let problemId: UUID?
let gymId: UUID?
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@@ -11,63 +11,40 @@ struct AddEditProblemView: View {
@State private var selectedGym: Gym?
@State private var name = ""
@State private var description = ""
@State private var selectedClimbType: ClimbType = .boulder
@State private var selectedDifficultySystem: DifficultySystem = .vScale
@State private var selectedClimbType: ClimbType
@State private var selectedDifficultySystem: DifficultySystem
@State private var difficultyGrade = ""
@State private var availableDifficultySystems: [DifficultySystem] = []
@State private var location = ""
@State private var tags = ""
@State private var notes = ""
@State private var isActive = true
@State private var dateSet = Date()
@State private var imagePaths: [String] = []
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var imageData: [Data] = []
@State private var showingPhotoOptions = false
@State private var showingCamera = false
@State private var showingImagePicker = false
@State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
@State private var isEditing = false
enum SheetType: Identifiable {
case photoOptions
var id: Int {
switch self {
case .photoOptions: return 0
}
}
}
@State private var activeSheet: SheetType?
@State private var showCamera = false
@State private var showPhotoPicker = false
@State private var isPhotoPickerActionPending = false
@State private var isCameraActionPending = false
private var existingProblem: Problem? {
guard let problemId = problemId else { return nil }
return dataManager.problem(withId: problemId)
}
private var availableClimbTypes: [ClimbType] {
selectedGym?.supportedClimbTypes ?? ClimbType.allCases
private var existingProblemGym: Gym? {
guard let problem = existingProblem else { return nil }
return dataManager.gym(withId: problem.gymId)
}
var availableDifficultySystems: [DifficultySystem] {
guard let gym = selectedGym else {
return DifficultySystem.systemsForClimbType(selectedClimbType)
private var gymId: UUID? {
return selectedGym?.id ?? existingProblemGym?.id
}
let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
let gymSupportedSystems = gym.difficultySystems.filter { system in
compatibleSystems.contains(system)
}
return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
}
private var availableGrades: [String] {
selectedDifficultySystem.availableGrades
}
init(problemId: UUID? = nil, gymId: UUID? = nil) {
init(problemId: UUID? = nil) {
self.problemId = problemId
self.gymId = gymId
self._selectedClimbType = State(initialValue: .boulder)
self._selectedDifficultySystem = State(initialValue: .vScale)
}
var body: some View {
@@ -75,14 +52,9 @@ struct AddEditProblemView: View {
Form {
GymSelectionSection()
BasicInfoSection()
PhotosSection()
ClimbTypeSection()
DifficultySection()
LocationSection()
TagsSection()
AdditionalInfoSection()
}
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
.navigationTitle(isEditing ? "Edit Problem" : "New Problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@@ -100,73 +72,41 @@ struct AddEditProblemView: View {
}
}
.onAppear {
setupInitialClimbType()
loadExistingProblem()
setupInitialGym()
}
.onChange(of: dataManager.gyms) {
if selectedGym == nil && !dataManager.gyms.isEmpty {
selectedGym = dataManager.gyms.first
}
}
.onChange(of: selectedGym) {
updateAvailableOptions()
}
.onChange(of: selectedClimbType) {
updateDifficultySystem()
}
.onChange(of: selectedDifficultySystem) {
resetGradeIfNeeded()
}
.sheet(
item: $activeSheet,
onDismiss: {
if isCameraActionPending {
showCamera = true
isCameraActionPending = false
return
}
if isPhotoPickerActionPending {
showPhotoPicker = true
isPhotoPickerActionPending = false
}
}
) { sheetType in
switch sheetType {
case .photoOptions:
.sheet(isPresented: $showingPhotoOptions) {
PhotoOptionSheet(
selectedPhotos: $selectedPhotos,
selectedPhotos: .constant([]),
imageData: $imageData,
maxImages: 5,
onCameraSelected: {
isCameraActionPending = true
activeSheet = nil
showingCamera = true
},
onPhotoLibrarySelected: {
isPhotoPickerActionPending = true
showingImagePicker = true
},
onDismiss: {
activeSheet = nil
showingPhotoOptions = false
}
)
}
}
.fullScreenCover(isPresented: $showCamera) {
CameraImagePicker { capturedImage in
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
imageData.append(jpegData)
.sheet(isPresented: $showingCamera) {
CameraImagePicker { image in
if let data = image.jpegData(compressionQuality: 0.8) {
imageData.append(data)
}
}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotos,
maxSelectionCount: 5 - imageData.count,
matching: .images
.sheet(isPresented: $showingImagePicker) {
ImagePicker(
selectedImages: Binding(
get: { imageData },
set: { imageData = $0 }
),
sourceType: imageSource,
selectionLimit: 5
)
.onChange(of: selectedPhotos) {
Task {
await loadSelectedPhotos()
}
}
}
@@ -178,6 +118,16 @@ struct AddEditProblemView: View {
.foregroundColor(.secondary)
} else {
ForEach(dataManager.gyms, id: \.id) { gym in
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
.onTapGesture {
selectedGym = gym
}
}
}
}
}
private func gymRow(gym: Gym, isSelected: Bool) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
@@ -192,18 +142,12 @@ struct AddEditProblemView: View {
Spacer()
if selectedGym?.id == gym.id {
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(themeManager.accentColor)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedGym = gym
}
}
}
}
}
@ViewBuilder
@@ -226,217 +170,6 @@ struct AddEditProblemView: View {
}
}
@ViewBuilder
private func ClimbTypeSection() -> some View {
if selectedGym != nil {
Section("Climb Type") {
ForEach(availableClimbTypes, id: \.self) { climbType in
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(themeManager.accentColor)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = climbType
}
}
}
}
}
@ViewBuilder
private func DifficultySection() -> some View {
Section("Difficulty") {
// Difficulty System
VStack(alignment: .leading, spacing: 8) {
Text("Difficulty System")
.font(.headline)
ForEach(availableDifficultySystems, id: \.self) { system in
HStack {
Text(system.displayName)
Spacer()
if selectedDifficultySystem == system {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(themeManager.accentColor)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedDifficultySystem = system
}
}
}
// Grade Selection
VStack(alignment: .leading, spacing: 8) {
Text("Grade (Required)")
.font(.headline)
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
TextField("Enter custom grade (numbers only)", text: $difficultyGrade)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.onChange(of: difficultyGrade) {
// Filter out non-numeric characters
difficultyGrade = difficultyGrade.filter { $0.isNumber }
}
} else {
Menu {
if !difficultyGrade.isEmpty {
Button("Clear Selection") {
difficultyGrade = ""
}
Divider()
}
ForEach(availableGrades, id: \.self) { grade in
Button(grade) {
difficultyGrade = grade
}
}
} label: {
HStack {
Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
.foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
.fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
.font(.caption)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.1))
.stroke(
difficultyGrade.isEmpty
? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
if difficultyGrade.isEmpty {
Text("Please select a grade to continue")
.font(.caption)
.foregroundColor(.red)
.italic()
} else {
Text("Selected: \(difficultyGrade)")
.font(.caption)
.foregroundColor(themeManager.accentColor)
}
}
}
}
@ViewBuilder
private func LocationSection() -> some View {
Section("Location & Details") {
TextField(
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
DatePicker(
"Date Set",
selection: $dateSet,
displayedComponents: [.date]
)
}
}
@ViewBuilder
private func TagsSection() -> some View {
Section("Tags (Optional)") {
TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
}
}
@ViewBuilder
private func PhotosSection() -> some View {
Section("Photos (Optional)") {
Button(action: {
activeSheet = .photoOptions
}) {
HStack {
Image(systemName: "camera.fill")
.foregroundColor(themeManager.accentColor)
.font(.title2)
VStack(alignment: .leading, spacing: 2) {
Text("Add Photos")
.font(.headline)
.foregroundColor(themeManager.accentColor)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.disabled(imageData.count >= 5)
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
ZStack(alignment: .topTrailing) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
Button(action: {
imageData.remove(at: index)
if index < imagePaths.count {
imagePaths.remove(at: index)
}
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Circle().fill(.white))
.font(.system(size: 18))
}
.offset(x: 4, y: -4)
}
.frame(width: 88, height: 88) // Extra space for button
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.frame(width: 80, height: 80)
.overlay {
Image(systemName: "photo")
.foregroundColor(.gray)
}
}
}
}
.padding(.horizontal, 1)
.padding(.vertical, 8)
}
}
}
}
@ViewBuilder
private func AdditionalInfoSection() -> some View {
Section("Additional Information") {
@@ -458,11 +191,10 @@ struct AddEditProblemView: View {
}
private var canSave: Bool {
selectedGym != nil
&& !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
selectedGym != nil && difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
private func setupInitialGym() {
private func setupInitialClimbType() {
if let gymId = gymId {
selectedGym = dataManager.gym(withId: gymId)
}
@@ -496,140 +228,59 @@ struct AddEditProblemView: View {
imageData.append(data)
}
}
if let dateSet = problem.dateSet {
self.dateSet = dateSet
}
}
}
private func updateAvailableOptions() {
guard let gym = selectedGym else { return }
// Auto-select climb type if there's only one available
if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
selectedClimbType = gym.supportedClimbTypes.first!
}
updateDifficultySystem()
}
private func updateDifficultySystem() {
let available = availableDifficultySystems
if !available.contains(selectedDifficultySystem) {
selectedDifficultySystem = available.first ?? .custom
}
if available.count == 1, selectedDifficultySystem != available.first! {
selectedDifficultySystem = available.first!
}
}
private func resetGradeIfNeeded() {
let availableGrades = selectedDifficultySystem.availableGrades
if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
difficultyGrade = ""
}
}
private func loadSelectedPhotos() async {
for item in selectedPhotos {
if let data = try? await item.loadTransferable(type: Data.self) {
imageData.append(data)
}
}
selectedPhotos.removeAll()
}
private func saveProblem() {
guard let gym = selectedGym, canSave else { return }
guard let gym = selectedGym else { return }
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTags = tags.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty }
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
let tempImagePaths = imagePaths.filter { !$0.isEmpty && !imagePaths.contains($0) }
for imagePath in tempImagePaths {
_ = ImageManager.shared.deleteImage(atPath: imagePath)
}
let newImagePaths = imagePaths.filter { !$0.isEmpty }
if isEditing, let problem = existingProblem {
var allImagePaths = imagePaths
let newImagesStartIndex = imagePaths.count
if imageData.count > newImagesStartIndex {
for i in newImagesStartIndex..<imageData.count {
let data = imageData[i]
let imageIndex = allImagePaths.count
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: problem.id.uuidString, imageIndex: imageIndex)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
allImagePaths.append(relativePath)
}
}
}
let updatedProblem = problem.updated(
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: difficulty,
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: allImagePaths,
imagePaths: newImagePaths.isEmpty ? [] : newImagePaths,
isActive: isActive,
dateSet: dateSet,
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.updateProblem(updatedProblem)
dismiss()
} else {
let newProblem = Problem(
let problem = Problem(
gymId: gym.id,
name: trimmedName.isEmpty ? nil : trimmedName,
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
climbType: selectedClimbType,
difficulty: difficulty,
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
tags: trimmedTags,
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
imagePaths: [],
dateSet: dateSet,
imagePaths: newImagePaths.isEmpty ? [] : newImagePaths,
dateSet: Date(),
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
)
dataManager.addProblem(newProblem)
if !imageData.isEmpty {
var imagePaths: [String] = []
for (index, data) in imageData.enumerated() {
let deterministicName = ImageNamingUtils.generateImageFilename(
problemId: newProblem.id.uuidString, imageIndex: index)
if let relativePath = ImageManager.shared.saveImageData(
data, withName: deterministicName)
{
imagePaths.append(relativePath)
}
}
if !imagePaths.isEmpty {
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
dataManager.updateProblem(updatedProblem)
}
}
}
dataManager.addProblem(problem)
dismiss()
}
}
}
#Preview {
AddEditProblemView()

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