Compare commits
27 Commits
ANDROID_2.
...
main
| 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
|
# Documentation
|
||||||
LICENSE text eol=lf
|
LICENSE text eol=lf
|
||||||
README.md text eol=lf
|
README.md text eol=lf
|
||||||
|
|
||||||
|
*.pxd linguist-vendored
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Ascently
|
# Ascently
|
||||||
|
|
||||||
|
<img src="https://git.atri.dad/atridad/Ascently/raw/branch/main/docs/src/assets/logo.png" alt="Ascently Logo" width="250" height="250">
|
||||||
|
|
||||||
_Formerly OpenClimb_
|
_Formerly OpenClimb_
|
||||||
|
|
||||||
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
|
Ascently is an **offline-first FOSS** app designed to help climbers track their sessions, routes/problems, and overall progress. There is an optional self-hosted sync server and integrations with Apple Health and Health Connect. There are no analytics or tracking baked into any part of this project. I am committed to maintaining a transparent and open-source solution for climbers, ensuring that you have full control over your data and privacy.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ android {
|
|||||||
applicationId = "com.atridad.ascently"
|
applicationId = "com.atridad.ascently"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 51
|
versionCode = 52
|
||||||
versionName = "2.5.1"
|
versionName = "2.5.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ androidxTestExt = "1.3.0"
|
|||||||
androidxTestRunner = "1.7.0"
|
androidxTestRunner = "1.7.0"
|
||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.2"
|
activityCompose = "1.12.3"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2026.01.01"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
navigation = "2.9.6"
|
navigation = "2.9.7"
|
||||||
viewmodel = "2.10.0"
|
viewmodel = "2.10.0"
|
||||||
kotlinxSerialization = "1.9.0"
|
kotlinxSerialization = "1.10.0"
|
||||||
kotlinxCoroutines = "1.10.2"
|
kotlinxCoroutines = "1.10.2"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
ksp = "2.2.20-2.0.3"
|
ksp = "2.2.20-2.0.3"
|
||||||
exifinterface = "1.4.2"
|
exifinterface = "1.4.2"
|
||||||
healthConnect = "1.1.0"
|
healthConnect = "1.1.0"
|
||||||
detekt = "1.23.8"
|
detekt = "1.23.8"
|
||||||
spotless = "8.1.0"
|
spotless = "8.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: "https://docs.ascently.app",
|
site: "https://docs.ascently.app",
|
||||||
|
|
||||||
integrations: [
|
integrations: [
|
||||||
starlight({
|
starlight({
|
||||||
title: "Ascently",
|
title: "Ascently",
|
||||||
description:
|
description:
|
||||||
"An offline-first FOSS climb tracking app with an optional sync server.",
|
"An offline-first FOSS climb tracking app with an optional sync server.",
|
||||||
logo: {
|
logo: {
|
||||||
light: "./src/assets/logo.svg",
|
light: "./src/assets/logo.png",
|
||||||
dark: "./src/assets/logo-dark.svg",
|
dark: "./src/assets/logo.png",
|
||||||
},
|
},
|
||||||
favicon: "/favicon.png",
|
favicon: "/favicon.png",
|
||||||
social: [
|
social: [
|
||||||
{
|
{
|
||||||
icon: "seti:git",
|
icon: "seti:git",
|
||||||
label: "Gitea",
|
label: "Gitea",
|
||||||
href: "https://git.atri.dad/atridad/Ascently",
|
href: "https://git.atri.dad/atridad/Ascently",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "email",
|
icon: "email",
|
||||||
label: "Contact",
|
label: "Contact",
|
||||||
href: "mailto:me@atri.dad",
|
href: "mailto:me@atri.dad",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{
|
{
|
||||||
label: "Download",
|
label: "Download",
|
||||||
link: "/download/",
|
link: "/download/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Self-Hosted Sync",
|
label: "Self-Hosted Sync",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Overview", slug: "sync/overview" },
|
{ label: "Overview", slug: "sync/overview" },
|
||||||
{ label: "Quick Start", slug: "sync/quick-start" },
|
{ label: "Quick Start", slug: "sync/quick-start" },
|
||||||
{ label: "API Reference", slug: "sync/api-reference" },
|
{ label: "API Reference", slug: "sync/api-reference" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Privacy",
|
label: "Privacy",
|
||||||
link: "/privacy/",
|
link: "/privacy/",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
customCss: ["./src/styles/custom.css"],
|
customCss: ["./src/styles/custom.css"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
adapter: node({
|
||||||
|
mode: "standalone",
|
||||||
}),
|
}),
|
||||||
],
|
|
||||||
|
|
||||||
adapter: node({
|
output: "server",
|
||||||
mode: "standalone",
|
|
||||||
}),
|
build: {
|
||||||
|
inlineStylesheets: "always",
|
||||||
|
},
|
||||||
|
|
||||||
|
experimental: {
|
||||||
|
svgo: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.1",
|
"@astrojs/node": "^9.5.2",
|
||||||
"@astrojs/starlight": "^0.37.1",
|
"@astrojs/starlight": "^0.37.5",
|
||||||
"astro": "^5.16.5",
|
"astro": "^5.17.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5"
|
"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:
|
hero:
|
||||||
tagline: Track your climbing sessions, routes, and progress.
|
tagline: Track your climbing sessions, routes, and progress.
|
||||||
image:
|
image:
|
||||||
file: ../../assets/logo-highres.svg
|
file: ../../assets/logo.png
|
||||||
alt: "Ascently app icon"
|
alt: "Ascently app icon"
|
||||||
actions:
|
actions:
|
||||||
- text: Download
|
- text: Download
|
||||||
|
|||||||
31
docs/src/middleware.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineMiddleware } from "astro:middleware";
|
||||||
|
|
||||||
|
export const onRequest = defineMiddleware(async (_, next) => {
|
||||||
|
const response = await next();
|
||||||
|
|
||||||
|
const contentType = response.headers.get("Content-Type") || "";
|
||||||
|
|
||||||
|
// Only modify HTML responses
|
||||||
|
if (contentType.includes("text/html")) {
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// Optimize LCP image by setting fetchpriority="high" on the hero image
|
||||||
|
// Target specific image by its alt text seen in PageSpeed Insights
|
||||||
|
const optimizedHtml = html.replace(
|
||||||
|
/<img([^>]*?)alt="Ascently app icon"([^>]*?)>/,
|
||||||
|
(match, p1, p2) => {
|
||||||
|
if (match.includes("fetchpriority=")) {
|
||||||
|
return match.replace(/fetchpriority="[^"]*"/, 'fetchpriority="high"');
|
||||||
|
}
|
||||||
|
return `<img${p1}alt="Ascently app icon" fetchpriority="high"${p2}>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(optimizedHtml, {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
# SwiftFormat Configuration for Ascently iOS
|
# SwiftFormat Configuration for Ascently iOS
|
||||||
# Maintains consistent formatting across the project
|
|
||||||
|
|
||||||
# File options
|
# File options
|
||||||
--exclude build,Pods,DerivedData,.build
|
--exclude build,Pods,DerivedData,.build
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ identifier_name:
|
|||||||
- DATA_JSON_FILENAME
|
- DATA_JSON_FILENAME
|
||||||
- IMAGES_DIR_NAME
|
- IMAGES_DIR_NAME
|
||||||
- METADATA_FILENAME
|
- METADATA_FILENAME
|
||||||
# ViewBuilder section functions (SwiftUI convention)
|
|
||||||
- StatusSection
|
- StatusSection
|
||||||
- IconDisplaySection
|
- IconDisplaySection
|
||||||
- DebugSection
|
- DebugSection
|
||||||
|
|||||||
@@ -460,13 +460,13 @@
|
|||||||
D24C19742E75002A0045894C /* Debug */ = {
|
D24C19742E75002A0045894C /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = Balls;
|
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "Balls Pride Trans Lesbian Enby Bi Pan Gay Loss";
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Peaks;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -491,7 +491,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.6.1;
|
MARKETING_VERSION = 2.7.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -512,13 +512,13 @@
|
|||||||
D24C19752E75002A0045894C /* Release */ = {
|
D24C19752E75002A0045894C /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = Balls;
|
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "Balls Pride Trans Lesbian Enby Bi Pan Gay Loss";
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Peaks;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -543,7 +543,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
MACOSX_DEPLOYMENT_TARGET = 15.6;
|
||||||
MARKETING_VERSION = 2.6.1;
|
MARKETING_VERSION = 2.7.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -610,7 +610,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.6.1;
|
MARKETING_VERSION = 2.7.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -641,7 +641,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 44;
|
CURRENT_PROJECT_VERSION = 53;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -653,7 +653,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.6.1;
|
MARKETING_VERSION = 2.7.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.Ascently.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
@@ -2,13 +2,25 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>aps-environment</key>
|
||||||
<array>
|
<string>development</string>
|
||||||
<string>group.com.atridad.Ascently</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.healthkit</key>
|
<key>com.apple.developer.healthkit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.access</key>
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
<array/>
|
<array/>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.com.atridad.Ascently</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
|
<string>$(TeamIdentifierPrefix)com.atridad.Ascently</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.atridad.Ascently</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
BIN
ios/Ascently/Assets.xcassets/AscentlyBlueBall.imageset/AscentlyBlueBall.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyBlueBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"filename": "AscentlyBlueBall.png",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Ascently/Assets.xcassets/AscentlyGreenBall.imageset/AscentlyGreenBall.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyGreenBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"filename": "AscentlyGreenBall.png",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Ascently/Assets.xcassets/AscentlyRedBall.imageset/AscentlyRedBall.png
vendored
Normal file
|
After Width: | Height: | Size: 76 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyRedBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"filename": "AscentlyRedBall.png",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Ascently/Assets.xcassets/AscentlyYellowBall.imageset/AscentlyYellowBall.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
21
ios/Ascently/Assets.xcassets/AscentlyYellowBall.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"filename": "AscentlyYellowBall.png",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.environmentObject(dataManager)
|
.environmentObject(dataManager)
|
||||||
.environmentObject(MusicService.shared)
|
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .active {
|
if newPhase == .active {
|
||||||
// Add slight delay to ensure app is fully loaded
|
// 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 Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit // Needed for UIImage/Data handling if not using ImageManager exclusively
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class ServerSyncProvider: SyncProvider {
|
class ServerSyncProvider: SyncProvider {
|
||||||
@@ -206,7 +206,6 @@ class ServerSyncProvider: SyncProvider {
|
|||||||
throw SyncError.invalidURL
|
throw SyncError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last sync time, or use epoch if never synced
|
|
||||||
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
|
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
let lastSyncString = formatter.string(from: lastSync)
|
let lastSyncString = formatter.string(from: lastSync)
|
||||||
@@ -268,7 +267,6 @@ class ServerSyncProvider: SyncProvider {
|
|||||||
tag: logTag
|
tag: logTag
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create delta request
|
|
||||||
let deltaRequest = DeltaSyncRequest(
|
let deltaRequest = DeltaSyncRequest(
|
||||||
lastSyncTime: lastSyncString,
|
lastSyncTime: lastSyncString,
|
||||||
gyms: modifiedGyms,
|
gyms: modifiedGyms,
|
||||||
@@ -316,13 +314,10 @@ class ServerSyncProvider: SyncProvider {
|
|||||||
tag: logTag
|
tag: logTag
|
||||||
)
|
)
|
||||||
|
|
||||||
// Apply server changes to local data
|
|
||||||
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
|
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
|
||||||
|
|
||||||
// Upload images for modified problems
|
|
||||||
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
|
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
|
||||||
|
|
||||||
// Update last sync time to server time
|
|
||||||
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
|
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
|
||||||
lastSyncTime = serverTime
|
lastSyncTime = serverTime
|
||||||
}
|
}
|
||||||
@@ -545,7 +540,6 @@ class ServerSyncProvider: SyncProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
|
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
|
||||||
// Simple mapping
|
|
||||||
let gyms = dataManager.gyms.map { BackupGym(from: $0) }
|
let gyms = dataManager.gyms.map { BackupGym(from: $0) }
|
||||||
let problems = dataManager.problems.map { BackupProblem(from: $0) }
|
let problems = dataManager.problems.map { BackupProblem(from: $0) }
|
||||||
let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) }
|
let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) }
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ enum SyncProviderType: String, CaseIterable, Identifiable {
|
|||||||
case none
|
case none
|
||||||
case server
|
case server
|
||||||
case iCloud
|
case iCloud
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none: return "None"
|
case .none: return "None"
|
||||||
case .server: return "Self-Hosted Server"
|
case .server: return "Self-Hosted Server"
|
||||||
case .iCloud: return "iCloud"
|
case .iCloud: return "iCloud (BETA)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,8 @@ protocol SyncProvider {
|
|||||||
var type: SyncProviderType { get }
|
var type: SyncProviderType { get }
|
||||||
var isConfigured: Bool { get }
|
var isConfigured: Bool { get }
|
||||||
var isConnected: Bool { get }
|
var isConnected: Bool { get }
|
||||||
|
var lastSyncTime: Date? { get }
|
||||||
|
|
||||||
func sync(dataManager: ClimbingDataManager) async throws
|
func sync(dataManager: ClimbingDataManager) async throws
|
||||||
func testConnection() async throws
|
func testConnection() async throws
|
||||||
func disconnect()
|
func disconnect()
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ class SyncService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
|
|
||||||
self.lastSyncTime = lastSync
|
|
||||||
}
|
|
||||||
isConnected = userDefaults.bool(forKey: Keys.isConnected)
|
isConnected = userDefaults.bool(forKey: Keys.isConnected)
|
||||||
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
|
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
|
||||||
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
|
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
|
||||||
@@ -80,8 +77,7 @@ class SyncService: ObservableObject {
|
|||||||
case .server:
|
case .server:
|
||||||
activeProvider = ServerSyncProvider()
|
activeProvider = ServerSyncProvider()
|
||||||
case .iCloud:
|
case .iCloud:
|
||||||
// Placeholder for iCloud provider
|
activeProvider = ICloudSyncProvider()
|
||||||
activeProvider = nil
|
|
||||||
case .none:
|
case .none:
|
||||||
activeProvider = nil
|
activeProvider = nil
|
||||||
}
|
}
|
||||||
@@ -89,8 +85,10 @@ class SyncService: ObservableObject {
|
|||||||
// Update status based on new provider
|
// Update status based on new provider
|
||||||
if let provider = activeProvider {
|
if let provider = activeProvider {
|
||||||
isConnected = provider.isConnected
|
isConnected = provider.isConnected
|
||||||
|
lastSyncTime = provider.lastSyncTime
|
||||||
} else {
|
} else {
|
||||||
isConnected = false
|
isConnected = false
|
||||||
|
lastSyncTime = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +125,7 @@ class SyncService: ObservableObject {
|
|||||||
try await provider.sync(dataManager: dataManager)
|
try await provider.sync(dataManager: dataManager)
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
// Provider might have updated it in UserDefaults, reload it
|
self.lastSyncTime = provider.lastSyncTime
|
||||||
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date {
|
|
||||||
self.lastSyncTime = lastSync
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
syncError = error.localizedDescription
|
syncError = error.localizedDescription
|
||||||
throw error
|
throw error
|
||||||
@@ -204,7 +199,6 @@ class SyncService: ObservableObject {
|
|||||||
// These are shared keys, so clearing them affects all providers if they use them
|
// These are shared keys, so clearing them affects all providers if they use them
|
||||||
// But disconnect() is usually user initiated action
|
// But disconnect() is usually user initiated action
|
||||||
userDefaults.set(false, forKey: Keys.isConnected)
|
userDefaults.set(false, forKey: Keys.isConnected)
|
||||||
userDefaults.removeObject(forKey: Keys.lastSyncTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearConfiguration() {
|
func clearConfiguration() {
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||