Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
77f7092287
|
|||
|
ed25cf7ecd
|
|||
|
255f85c2df
|
|||
|
a3d47d29c5
|
|||
|
b94b823986
|
|||
|
58d84af29b
|
|||
|
12f9463e8c
|
|||
|
aa3ddfc7cb
|
|||
|
25688b0615
|
|||
|
3874703fcb
|
|||
| aa08892e75 | |||
|
4da10912fc
|
|||
|
94d2f9d951
|
|||
|
6e679236c8
|
|||
|
06fe659478
|
|||
|
390b4bf499
|
|||
|
394789d609
|
|||
|
94566eabf6
|
|||
|
c020287d1f
|
|||
|
98589645e6
|
|||
|
33610a5959
|
|||
|
20058e9ac0
|
|||
|
e4d6e6fb7e
|
|||
|
d97a5f36ea
|
|||
| 1a85dab6ae | |||
|
2d5382ba28
|
|||
|
05c0430b40
|
2
.gitattributes
vendored
@@ -75,3 +75,5 @@ pnpm-lock.yaml text -diff
|
||||
# Documentation
|
||||
LICENSE text eol=lf
|
||||
README.md text eol=lf
|
||||
|
||||
*.pxd linguist-vendored
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
BIN
branding/Photomator Files/Ascently_iPad_1.pxd
vendored
Normal file
BIN
branding/Photomator Files/Ascently_iPad_2.pxd
vendored
Normal file
BIN
branding/Photomator Files/Ascently_iPad_3.pxd
vendored
Normal file
BIN
branding/iOS/Balls-iOS-ClearDark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 698 KiB |
BIN
branding/iOS/Balls-iOS-ClearLight-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 657 KiB |
BIN
branding/iOS/Balls-iOS-Dark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
branding/iOS/Balls-iOS-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
branding/iOS/Balls-iOS-TintedDark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
branding/iOS/Balls-iOS-TintedLight-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 707 KiB |
BIN
branding/iOS/Balls-watchOS-Default-1088x1088@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
Before Width: | Height: | Size: 166 B |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 42 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="3.000,26.636 10.736,9.231 18.471,26.636" fill="#FFC107"/>
|
||||
<polygon points="9.661,26.636 19.331,5.364 29.000,26.636" fill="#F44336"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 244 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="24.000,213.091 85.884,73.851 147.769,213.091" fill="#FFC107"/>
|
||||
<polygon points="77.289,213.091 154.645,42.909 232.000,213.091" fill="#F44336"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 259 B |
BIN
docs/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 1.2 MiB |
@@ -5,7 +5,7 @@ template: splash
|
||||
hero:
|
||||
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
@@ -0,0 +1,31 @@
|
||||
import { defineMiddleware } from "astro:middleware";
|
||||
|
||||
export const onRequest = defineMiddleware(async (_, next) => {
|
||||
const response = await next();
|
||||
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
|
||||
// Only modify HTML responses
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = await response.text();
|
||||
|
||||
// Optimize LCP image by setting fetchpriority="high" on the hero image
|
||||
// Target specific image by its alt text seen in PageSpeed Insights
|
||||
const optimizedHtml = html.replace(
|
||||
/<img([^>]*?)alt="Ascently app icon"([^>]*?)>/,
|
||||
(match, p1, p2) => {
|
||||
if (match.includes("fetchpriority=")) {
|
||||
return match.replace(/fetchpriority="[^"]*"/, 'fetchpriority="high"');
|
||||
}
|
||||
return `<img${p1}alt="Ascently app icon" fetchpriority="high"${p2}>`;
|
||||
}
|
||||
);
|
||||
|
||||
return new Response(optimizedHtml, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
# SwiftFormat Configuration for Ascently iOS
|
||||
# Maintains consistent formatting across the project
|
||||
|
||||
# File options
|
||||
--exclude build,Pods,DerivedData,.build
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ identifier_name:
|
||||
- DATA_JSON_FILENAME
|
||||
- IMAGES_DIR_NAME
|
||||
- METADATA_FILENAME
|
||||
# ViewBuilder section functions (SwiftUI convention)
|
||||
- StatusSection
|
||||
- IconDisplaySection
|
||||
- DebugSection
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
ios/Ascently/Assets.xcassets/AscentlyBlueBall.imageset/AscentlyBlueBall.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyBlueBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "AscentlyBlueBall.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Assets.xcassets/AscentlyGreenBall.imageset/AscentlyGreenBall.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyGreenBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "AscentlyGreenBall.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Assets.xcassets/AscentlyRedBall.imageset/AscentlyRedBall.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyRedBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "AscentlyRedBall.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Assets.xcassets/AscentlyYellowBall.imageset/AscentlyYellowBall.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyYellowBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "AscentlyYellowBall.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
21
ios/Ascently/Assets.xcassets/AscetlyTriangle1.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "AscetlyTriangle1.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
21
ios/Ascently/Assets.xcassets/AscetlyTriangle2.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"filename": "AscetlyTriangle2.png",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Bi.icon/Assets/AscentlyBiBlue.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
ios/Ascently/Bi.icon/Assets/AscentlyBiPink.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Bi.icon/Assets/AscentlyBiPurple.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
56
ios/Ascently/Bi.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
ios/Ascently/Components/ImagePicker.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
BIN
ios/Ascently/Enby.icon/Assets/AscentlyEnbyBlack.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
ios/Ascently/Enby.icon/Assets/AscentlyEnbyPurple.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
ios/Ascently/Enby.icon/Assets/AscentlyEnbyWhite.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
ios/Ascently/Enby.icon/Assets/AscentlyEnbyYellow.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
73
ios/Ascently/Enby.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayBlue.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayDarkBlue.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayDarkGreen.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayGreen.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayLightBlue.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayLightGreen.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Gay.icon/Assets/AscentlyGayWhite.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
100
ios/Ascently/Gay.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Lesbian.icon/Assets/AscentlyLesbianDarkOrange.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
ios/Ascently/Lesbian.icon/Assets/AscentlyLesbianDarkRose.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Lesbian.icon/Assets/AscentlyLesbianDustyPink.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Lesbian.icon/Assets/AscentlyLesbianLightOrange.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
ios/Ascently/Lesbian.icon/Assets/AscentlyLesbianOrange.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Lesbian.icon/Assets/AscentlyLesbianPink.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
95
ios/Ascently/Lesbian.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Loss.icon/Assets/AscetlyTriangle1.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
ios/Ascently/Loss.icon/Assets/AscetlyTriangle2 2.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
ios/Ascently/Loss.icon/Assets/AscetlyTriangle2.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
100
ios/Ascently/Loss.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Pan.icon/Assets/AscentlyPanBlue.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
ios/Ascently/Pan.icon/Assets/AscentlyPanPink.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
ios/Ascently/Pan.icon/Assets/AscentlyPanYellow.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
56
ios/Ascently/Pan.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Ascently/Peaks.icon/Assets/AscetlyTriangle1.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
ios/Ascently/Peaks.icon/Assets/AscetlyTriangle2.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
ios/Ascently/Pride.icon/Assets/AscentlyPrideBlue.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
ios/Ascently/Pride.icon/Assets/AscentlyPrideGreen.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
ios/Ascently/Pride.icon/Assets/AscentlyPrideOrange.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
ios/Ascently/Pride.icon/Assets/AscentlyPridePurple.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Pride.icon/Assets/AscentlyPrideRed.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
ios/Ascently/Pride.icon/Assets/AscentlyPrideYellow.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
90
ios/Ascently/Pride.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
584
ios/Ascently/Services/Sync/ICloudSyncProvider.swift
Normal file
@@ -0,0 +1,584 @@
|
||||
import CloudKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ICloudSyncProvider: SyncProvider {
|
||||
// MARK: - Properties
|
||||
|
||||
var type: SyncProviderType { .iCloud }
|
||||
|
||||
var isConfigured: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
get { userDefaults.bool(forKey: Keys.isConnected) }
|
||||
set { userDefaults.set(newValue, forKey: Keys.isConnected) }
|
||||
}
|
||||
|
||||
var lastSyncTime: Date? {
|
||||
return userDefaults.object(forKey: Keys.lastSyncTime) as? Date
|
||||
}
|
||||
|
||||
private let container = CKContainer.default()
|
||||
private lazy var database = container.privateCloudDatabase
|
||||
private let zoneID = CKRecordZone.ID(zoneName: "AscentlyZone", ownerName: CKCurrentUserDefaultName)
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let logTag = "ICloudSync"
|
||||
|
||||
private enum Keys {
|
||||
static let serverChangeToken = "Ascently.ICloud.ServerChangeToken"
|
||||
static let lastSyncTime = "Ascently.ICloud.LastSyncTime"
|
||||
static let zoneCreated = "Ascently.ICloud.ZoneCreated"
|
||||
static let isConnected = "Ascently.ICloud.IsConnected"
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
Task {
|
||||
try? await checkAccountStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncProvider Protocol
|
||||
|
||||
func testConnection() async throws {
|
||||
try await checkAccountStatus()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
func sync(dataManager: ClimbingDataManager) async throws {
|
||||
if !isConnected {
|
||||
try await checkAccountStatus()
|
||||
if !isConnected {
|
||||
throw SyncError.notConnected
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.info("Starting iCloud sync", tag: logTag)
|
||||
|
||||
do {
|
||||
try await createZoneIfNeeded()
|
||||
|
||||
do {
|
||||
try await pullChanges(dataManager: dataManager)
|
||||
} catch {
|
||||
let errorString = String(describing: error)
|
||||
if errorString.contains("recordName") && errorString.contains("queryable") {
|
||||
AppLogger.warning("Schema initialization detected. Skipping pull to attempt self-healing via push.", tag: logTag)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try await pushChanges(dataManager: dataManager)
|
||||
|
||||
userDefaults.set(Date(), forKey: Keys.lastSyncTime)
|
||||
|
||||
AppLogger.info("iCloud sync completed successfully", tag: logTag)
|
||||
} catch {
|
||||
AppLogger.error("iCloud sync failed: \(error.localizedDescription)", tag: logTag)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func checkAccountStatus() async throws {
|
||||
let status: CKAccountStatus
|
||||
do {
|
||||
status = try await container.accountStatus()
|
||||
} catch {
|
||||
AppLogger.error("Failed to get iCloud account status: \(error). This often indicates a mismatch between the App Bundle ID and the iCloud Container configuration in the Provisioning Profile.", tag: logTag)
|
||||
throw error
|
||||
}
|
||||
|
||||
AppLogger.info("iCloud account status: \(status.rawValue)", tag: logTag)
|
||||
|
||||
switch status {
|
||||
case .available:
|
||||
isConnected = true
|
||||
case .noAccount:
|
||||
isConnected = false
|
||||
throw SyncError.providerError("No iCloud account found. Please sign in to iCloud settings.")
|
||||
case .restricted:
|
||||
isConnected = false
|
||||
throw SyncError.providerError("iCloud access is restricted.")
|
||||
case .couldNotDetermine:
|
||||
isConnected = false
|
||||
throw SyncError.providerError("Could not determine iCloud account status.")
|
||||
case .temporarilyUnavailable:
|
||||
isConnected = false
|
||||
throw SyncError.providerError("iCloud is temporarily unavailable.")
|
||||
@unknown default:
|
||||
isConnected = false
|
||||
throw SyncError.providerError("Unknown iCloud error.")
|
||||
}
|
||||
}
|
||||
|
||||
private func createZoneIfNeeded() async throws {
|
||||
if userDefaults.bool(forKey: Keys.zoneCreated) {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let zone = CKRecordZone(zoneID: zoneID)
|
||||
try await database.save(zone)
|
||||
userDefaults.set(true, forKey: Keys.zoneCreated)
|
||||
AppLogger.info("Created custom record zone: \(zoneID.zoneName)", tag: logTag)
|
||||
} catch {
|
||||
// If zone already exists, that's fine
|
||||
if let ckError = error as? CKError {
|
||||
if ckError.code == .serverRecordChanged {
|
||||
userDefaults.set(true, forKey: Keys.zoneCreated)
|
||||
return
|
||||
}
|
||||
|
||||
if ckError.code == .permissionFailure {
|
||||
AppLogger.error("CloudKit Permission Failure. This usually indicates 'Invalid Bundle ID'. Please Clean Build Folder and ensure Provisioning Profile matches entitlements.", tag: logTag)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pull (Fetch from CloudKit)
|
||||
|
||||
private func pullChanges(dataManager: ClimbingDataManager) async throws {
|
||||
var previousToken: CKServerChangeToken? = nil
|
||||
if let tokenData = userDefaults.data(forKey: Keys.serverChangeToken) {
|
||||
previousToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData)
|
||||
}
|
||||
|
||||
var changedRecords: [CKRecord] = []
|
||||
var deletedRecordIDs: [CKRecord.ID] = []
|
||||
|
||||
let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
||||
config.previousServerChangeToken = previousToken
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let operation = CKFetchRecordZoneChangesOperation(recordZoneIDs: [zoneID], configurationsByRecordZoneID: [zoneID: config])
|
||||
|
||||
operation.recordWasChangedBlock = { recordID, result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
changedRecords.append(record)
|
||||
case .failure(let error):
|
||||
AppLogger.error("Failed to fetch record \(recordID): \(error)", tag: self.logTag)
|
||||
}
|
||||
}
|
||||
|
||||
operation.recordWithIDWasDeletedBlock = { recordID, _ in
|
||||
deletedRecordIDs.append(recordID)
|
||||
}
|
||||
|
||||
operation.recordZoneChangeTokensUpdatedBlock = { zoneID, token, _ in
|
||||
if let token = token {
|
||||
self.saveChangeToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
operation.fetchRecordZoneChangesResultBlock = { result in
|
||||
switch result {
|
||||
case .success:
|
||||
Task {
|
||||
await self.applyChanges(changedRecords: changedRecords, deletedRecordIDs: deletedRecordIDs, dataManager: dataManager)
|
||||
continuation.resume()
|
||||
}
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
database.add(operation)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveChangeToken(_ token: CKServerChangeToken) {
|
||||
if let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) {
|
||||
userDefaults.set(data, forKey: Keys.serverChangeToken)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyChanges(changedRecords: [CKRecord], deletedRecordIDs: [CKRecord.ID], dataManager: ClimbingDataManager) async {
|
||||
guard !changedRecords.isEmpty || !deletedRecordIDs.isEmpty else { return }
|
||||
|
||||
AppLogger.info("Applying CloudKit changes: \(changedRecords.count) updates, \(deletedRecordIDs.count) deletions", tag: logTag)
|
||||
|
||||
await MainActor.run {
|
||||
for recordID in deletedRecordIDs {
|
||||
let uuidString = recordID.recordName
|
||||
if let uuid = UUID(uuidString: uuidString) {
|
||||
if let gym = dataManager.gym(withId: uuid) { dataManager.deleteGym(gym) }
|
||||
else if let problem = dataManager.problem(withId: uuid) { dataManager.deleteProblem(problem) }
|
||||
else if let session = dataManager.session(withId: uuid) { dataManager.deleteSession(session) }
|
||||
else if let attempt = dataManager.attempts.first(where: { $0.id == uuid }) { dataManager.deleteAttempt(attempt) }
|
||||
}
|
||||
}
|
||||
|
||||
for record in changedRecords {
|
||||
guard let uuid = UUID(uuidString: record.recordID.recordName) else { continue }
|
||||
|
||||
switch record.recordType {
|
||||
case "Gym":
|
||||
if let gym = mapRecordToGym(record, id: uuid) {
|
||||
if let existing = dataManager.gym(withId: uuid) {
|
||||
if gym.updatedAt >= existing.updatedAt {
|
||||
dataManager.updateGym(gym)
|
||||
}
|
||||
} else {
|
||||
dataManager.addGym(gym)
|
||||
}
|
||||
}
|
||||
case "Problem":
|
||||
if let problem = mapRecordToProblem(record, id: uuid) {
|
||||
if let existing = dataManager.problem(withId: uuid) {
|
||||
if problem.updatedAt >= existing.updatedAt {
|
||||
dataManager.updateProblem(problem)
|
||||
}
|
||||
} else {
|
||||
dataManager.addProblem(problem)
|
||||
}
|
||||
}
|
||||
case "ClimbSession":
|
||||
if let session = mapRecordToSession(record, id: uuid) {
|
||||
if let existingIndex = dataManager.sessions.firstIndex(where: { $0.id == uuid }) {
|
||||
let existing = dataManager.sessions[existingIndex]
|
||||
if session.updatedAt >= existing.updatedAt {
|
||||
dataManager.sessions[existingIndex] = session
|
||||
dataManager.saveSessions()
|
||||
}
|
||||
} else {
|
||||
dataManager.sessions.append(session)
|
||||
dataManager.saveSessions()
|
||||
}
|
||||
}
|
||||
case "Attempt":
|
||||
if let attempt = mapRecordToAttempt(record, id: uuid) {
|
||||
if let existingIndex = dataManager.attempts.firstIndex(where: { $0.id == uuid }) {
|
||||
let existing = dataManager.attempts[existingIndex]
|
||||
if attempt.updatedAt >= existing.updatedAt {
|
||||
dataManager.attempts[existingIndex] = attempt
|
||||
dataManager.saveAttempts()
|
||||
}
|
||||
} else {
|
||||
dataManager.attempts.append(attempt)
|
||||
dataManager.saveAttempts()
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Push (Upload to CloudKit)
|
||||
|
||||
private func pushChanges(dataManager: ClimbingDataManager) async throws {
|
||||
let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date ?? Date.distantPast
|
||||
let modifiedGyms = dataManager.gyms.filter { $0.updatedAt > lastSync }
|
||||
let modifiedProblems = dataManager.problems.filter { $0.updatedAt > lastSync }
|
||||
let modifiedSessions = dataManager.sessions.filter { $0.updatedAt > lastSync }
|
||||
let modifiedAttempts = dataManager.attempts.filter { $0.createdAt > lastSync }
|
||||
|
||||
let deletedItems = dataManager.getDeletedItems().filter { item in
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
if let date = dateFormatter.date(from: item.deletedAt) {
|
||||
return date > lastSync
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if modifiedGyms.isEmpty && modifiedProblems.isEmpty && modifiedSessions.isEmpty && modifiedAttempts.isEmpty && deletedItems.isEmpty {
|
||||
AppLogger.info("No local changes to push", tag: logTag)
|
||||
return
|
||||
}
|
||||
|
||||
var recordsToSave: [CKRecord] = []
|
||||
var recordIDsToDelete: [CKRecord.ID] = []
|
||||
|
||||
for item in deletedItems {
|
||||
recordIDsToDelete.append(CKRecord.ID(recordName: item.id, zoneID: zoneID))
|
||||
}
|
||||
|
||||
for gym in modifiedGyms {
|
||||
recordsToSave.append(createRecord(from: gym))
|
||||
}
|
||||
for problem in modifiedProblems {
|
||||
recordsToSave.append(createRecord(from: problem))
|
||||
}
|
||||
for session in modifiedSessions {
|
||||
recordsToSave.append(createRecord(from: session))
|
||||
}
|
||||
for attempt in modifiedAttempts {
|
||||
recordsToSave.append(createRecord(from: attempt))
|
||||
}
|
||||
|
||||
guard !recordsToSave.isEmpty || !recordIDsToDelete.isEmpty else { return }
|
||||
|
||||
AppLogger.info("Pushing to iCloud: \(recordsToSave.count) saves, \(recordIDsToDelete.count) deletions", tag: logTag)
|
||||
|
||||
let operation = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
|
||||
operation.savePolicy = .changedKeys // Merges changes if possible, simpler than handling tags manually
|
||||
operation.isAtomic = true // Transactional
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
operation.modifyRecordsResultBlock = { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
database.add(operation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mappers (To CKRecord)
|
||||
|
||||
private func createRecord(from gym: Gym) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: gym.id.uuidString, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: "Gym", recordID: recordID)
|
||||
|
||||
record["name"] = gym.name
|
||||
record["location"] = gym.location
|
||||
record["notes"] = gym.notes
|
||||
record["createdAt"] = gym.createdAt
|
||||
record["updatedAt"] = gym.updatedAt
|
||||
|
||||
record["supportedClimbTypes"] = encode(gym.supportedClimbTypes)
|
||||
record["difficultySystems"] = encode(gym.difficultySystems)
|
||||
record["customDifficultyGrades"] = encode(gym.customDifficultyGrades)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
private func createRecord(from problem: Problem) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: problem.id.uuidString, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: "Problem", recordID: recordID)
|
||||
|
||||
record["gymId"] = problem.gymId.uuidString
|
||||
record["name"] = problem.name
|
||||
record["description"] = problem.description
|
||||
record["climbType"] = problem.climbType.rawValue
|
||||
record["difficulty"] = encode(problem.difficulty)
|
||||
record["tags"] = encode(problem.tags)
|
||||
record["location"] = problem.location
|
||||
record["isActive"] = problem.isActive ? 1 : 0
|
||||
record["dateSet"] = problem.dateSet
|
||||
record["notes"] = problem.notes
|
||||
record["createdAt"] = problem.createdAt
|
||||
record["updatedAt"] = problem.updatedAt
|
||||
|
||||
var assets: [CKAsset] = []
|
||||
for path in problem.imagePaths {
|
||||
let fullPath = ImageManager.shared.getFullPath(from: path)
|
||||
let fileURL = URL(fileURLWithPath: fullPath)
|
||||
if FileManager.default.fileExists(atPath: fullPath) {
|
||||
assets.append(CKAsset(fileURL: fileURL))
|
||||
}
|
||||
}
|
||||
if !assets.isEmpty {
|
||||
record["images"] = assets
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
private func createRecord(from session: ClimbSession) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: session.id.uuidString, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: "ClimbSession", recordID: recordID)
|
||||
|
||||
record["gymId"] = session.gymId.uuidString
|
||||
record["date"] = session.date
|
||||
record["startTime"] = session.startTime
|
||||
record["endTime"] = session.endTime
|
||||
record["duration"] = session.duration
|
||||
record["status"] = session.status.rawValue
|
||||
record["notes"] = session.notes
|
||||
record["createdAt"] = session.createdAt
|
||||
record["updatedAt"] = session.updatedAt
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
private func createRecord(from attempt: Attempt) -> CKRecord {
|
||||
let recordID = CKRecord.ID(recordName: attempt.id.uuidString, zoneID: zoneID)
|
||||
let record = CKRecord(recordType: "Attempt", recordID: recordID)
|
||||
|
||||
record["sessionId"] = attempt.sessionId.uuidString
|
||||
record["problemId"] = attempt.problemId.uuidString
|
||||
record["result"] = attempt.result.rawValue
|
||||
record["highestHold"] = attempt.highestHold
|
||||
record["notes"] = attempt.notes
|
||||
record["duration"] = attempt.duration
|
||||
record["restTime"] = attempt.restTime
|
||||
record["timestamp"] = attempt.timestamp
|
||||
record["createdAt"] = attempt.createdAt
|
||||
record["updatedAt"] = attempt.updatedAt
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// MARK: - Mappers (From CKRecord)
|
||||
|
||||
private func mapRecordToGym(_ record: CKRecord, id: UUID) -> Gym? {
|
||||
guard let name = record["name"] as? String,
|
||||
let supportedClimbTypesData = record["supportedClimbTypes"] as? String,
|
||||
let difficultySystemsData = record["difficultySystems"] as? String,
|
||||
let createdAt = record["createdAt"] as? Date,
|
||||
let updatedAt = record["updatedAt"] as? Date
|
||||
else { return nil }
|
||||
|
||||
let location = record["location"] as? String
|
||||
let notes = record["notes"] as? String
|
||||
let customGradesData = record["customDifficultyGrades"] as? String ?? "[]"
|
||||
|
||||
let supportedClimbTypes: [ClimbType] = decode(supportedClimbTypesData) ?? []
|
||||
let difficultySystems: [DifficultySystem] = decode(difficultySystemsData) ?? []
|
||||
let customDifficultyGrades: [String] = decode(customGradesData) ?? []
|
||||
|
||||
return Gym.fromImport(
|
||||
id: id,
|
||||
name: name,
|
||||
location: location,
|
||||
supportedClimbTypes: supportedClimbTypes,
|
||||
difficultySystems: difficultySystems,
|
||||
customDifficultyGrades: customDifficultyGrades,
|
||||
notes: notes,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private func mapRecordToProblem(_ record: CKRecord, id: UUID) -> Problem? {
|
||||
guard let gymIdString = record["gymId"] as? String,
|
||||
let gymId = UUID(uuidString: gymIdString),
|
||||
let climbTypeRaw = record["climbType"] as? String,
|
||||
let climbType = ClimbType(rawValue: climbTypeRaw),
|
||||
let difficultyData = record["difficulty"] as? String,
|
||||
let difficulty: DifficultyGrade = decode(difficultyData),
|
||||
let createdAt = record["createdAt"] as? Date,
|
||||
let updatedAt = record["updatedAt"] as? Date
|
||||
else { return nil }
|
||||
|
||||
let name = record["name"] as? String
|
||||
let description = record["description"] as? String
|
||||
let tagsData = record["tags"] as? String ?? "[]"
|
||||
let tags: [String] = decode(tagsData) ?? []
|
||||
let location = record["location"] as? String
|
||||
let isActive = (record["isActive"] as? Int ?? 1) == 1
|
||||
let dateSet = record["dateSet"] as? Date
|
||||
let notes = record["notes"] as? String
|
||||
|
||||
var imagePaths: [String] = []
|
||||
if let assets = record["images"] as? [CKAsset] {
|
||||
for (index, asset) in assets.enumerated() {
|
||||
guard let fileURL = asset.fileURL else { continue }
|
||||
let filename = ImageNamingUtils.generateImageFilename(problemId: id.uuidString, imageIndex: index)
|
||||
|
||||
if let data = try? Data(contentsOf: fileURL) {
|
||||
_ = try? ImageManager.shared.saveImportedImage(data, filename: filename)
|
||||
imagePaths.append(filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Problem.fromImport(
|
||||
id: id,
|
||||
gymId: gymId,
|
||||
name: name,
|
||||
description: description,
|
||||
climbType: climbType,
|
||||
difficulty: difficulty,
|
||||
tags: tags,
|
||||
location: location,
|
||||
imagePaths: imagePaths,
|
||||
isActive: isActive,
|
||||
dateSet: dateSet,
|
||||
notes: notes,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private func mapRecordToSession(_ record: CKRecord, id: UUID) -> ClimbSession? {
|
||||
guard let gymIdString = record["gymId"] as? String,
|
||||
let gymId = UUID(uuidString: gymIdString),
|
||||
let date = record["date"] as? Date,
|
||||
let statusRaw = record["status"] as? String,
|
||||
let status = SessionStatus(rawValue: statusRaw),
|
||||
let createdAt = record["createdAt"] as? Date,
|
||||
let updatedAt = record["updatedAt"] as? Date
|
||||
else { return nil }
|
||||
|
||||
let startTime = record["startTime"] as? Date
|
||||
let endTime = record["endTime"] as? Date
|
||||
let duration = record["duration"] as? Int
|
||||
let notes = record["notes"] as? String
|
||||
|
||||
return ClimbSession.fromImport(
|
||||
id: id,
|
||||
gymId: gymId,
|
||||
date: date,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
duration: duration,
|
||||
status: status,
|
||||
notes: notes,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private func mapRecordToAttempt(_ record: CKRecord, id: UUID) -> Attempt? {
|
||||
guard let sessionIdString = record["sessionId"] as? String,
|
||||
let sessionId = UUID(uuidString: sessionIdString),
|
||||
let problemIdString = record["problemId"] as? String,
|
||||
let problemId = UUID(uuidString: problemIdString),
|
||||
let resultRaw = record["result"] as? String,
|
||||
let result = AttemptResult(rawValue: resultRaw),
|
||||
let timestamp = record["timestamp"] as? Date,
|
||||
let createdAt = record["createdAt"] as? Date,
|
||||
let updatedAt = record["updatedAt"] as? Date
|
||||
else { return nil }
|
||||
|
||||
let highestHold = record["highestHold"] as? String
|
||||
let notes = record["notes"] as? String
|
||||
let duration = record["duration"] as? Int
|
||||
let restTime = record["restTime"] as? Int
|
||||
|
||||
return Attempt.fromImport(
|
||||
id: id,
|
||||
sessionId: sessionId,
|
||||
problemId: problemId,
|
||||
result: result,
|
||||
highestHold: highestHold,
|
||||
notes: notes,
|
||||
duration: duration,
|
||||
restTime: restTime,
|
||||
timestamp: timestamp,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helper Coding
|
||||
|
||||
private func encode<T: Encodable>(_ value: T) -> String {
|
||||
guard let data = try? JSONEncoder().encode(value) else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
private func decode<T: Decodable>(_ json: String) -> T? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Combine
|
||||
import 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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
BIN
ios/Ascently/Trans.icon/Assets/AscentlyTransBlue 2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Trans.icon/Assets/AscentlyTransBlue.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Trans.icon/Assets/AscentlyTransPink 2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Trans.icon/Assets/AscentlyTransPink.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
ios/Ascently/Trans.icon/Assets/AscentlyTransWhite.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
78
ios/Ascently/Trans.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
99
ios/Ascently/Utils/AppExtensions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
ios/Ascently/Utils/DataHelpers.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||