Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
77f7092287
|
|||
|
ed25cf7ecd
|
|||
|
255f85c2df
|
|||
|
a3d47d29c5
|
|||
|
b94b823986
|
|||
|
58d84af29b
|
|||
|
12f9463e8c
|
|||
|
aa3ddfc7cb
|
|||
|
25688b0615
|
|||
|
3874703fcb
|
|||
| aa08892e75 | |||
|
4da10912fc
|
|||
|
94d2f9d951
|
|||
|
6e679236c8
|
|||
|
06fe659478
|
|||
|
390b4bf499
|
|||
|
394789d609
|
|||
|
94566eabf6
|
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,6 +1,6 @@
|
|||||||
# Ascently
|
# Ascently
|
||||||
|
|
||||||
<img src="https://git.atri.dad/atridad/Ascently/raw/branch/main/docs/src/assets/logo.svg" alt="Ascently Logo" width="250" height="250">
|
<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_
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export default defineConfig({
|
|||||||
description:
|
description:
|
||||||
"An offline-first FOSS climb tracking app with an optional sync server.",
|
"An offline-first FOSS climb tracking app with an optional sync server.",
|
||||||
logo: {
|
logo: {
|
||||||
light: "./src/assets/logo.svg",
|
light: "./src/assets/logo.png",
|
||||||
dark: "./src/assets/logo.svg",
|
dark: "./src/assets/logo.png",
|
||||||
},
|
},
|
||||||
favicon: "/favicon.png",
|
favicon: "/favicon.png",
|
||||||
social: [
|
social: [
|
||||||
|
|||||||
@@ -25,9 +25,9 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.5.1",
|
"@astrojs/node": "^9.5.2",
|
||||||
"@astrojs/starlight": "^0.37.2",
|
"@astrojs/starlight": "^0.37.5",
|
||||||
"astro": "^5.16.8",
|
"astro": "^5.17.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
|
|||||||
644
docs/pnpm-lock.yaml
generated
@@ -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.svg
|
file: ../../assets/logo.png
|
||||||
alt: "Ascently app icon"
|
alt: "Ascently app icon"
|
||||||
actions:
|
actions:
|
||||||
- text: Download
|
- text: Download
|
||||||
|
|||||||
@@ -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 = 46;
|
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.7.0;
|
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 = 46;
|
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.7.0;
|
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 = 46;
|
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.7.0;
|
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 = 46;
|
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.7.0;
|
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;
|
||||||
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,6 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
let syncService = SyncService()
|
let syncService = SyncService()
|
||||||
let healthKitService = HealthKitService.shared
|
let healthKitService = HealthKitService.shared
|
||||||
let musicService = MusicService.shared
|
|
||||||
|
|
||||||
@Published var isSyncing = false
|
@Published var isSyncing = false
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
@@ -427,8 +426,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
gymName: gym.name)
|
gymName: gym.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
musicService.resetSessionPlaybackState()
|
|
||||||
musicService.playSelectedPlaylistIfHeadphonesConnected()
|
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
do {
|
do {
|
||||||
@@ -470,10 +468,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
await LiveActivityManager.shared.endLiveActivity()
|
await LiveActivityManager.shared.endLiveActivity()
|
||||||
|
|
||||||
if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") {
|
|
||||||
musicService.stopPlaybackIfEnabled()
|
|
||||||
}
|
|
||||||
musicService.stopPlaybackIfEnabled()
|
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -373,35 +373,19 @@ struct AddAttemptView: View {
|
|||||||
Section("Additional Details") {
|
Section("Additional Details") {
|
||||||
TextField("Highest Hold (Optional)", text: $highestHold)
|
TextField("Highest Hold (Optional)", text: $highestHold)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("Notes (Optional)", text: $notes, axis: .vertical)
|
||||||
Text("Notes (Optional)")
|
.lineLimit(3...6)
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
TextEditor(text: $notes)
|
LabeledContent("Duration (seconds)") {
|
||||||
.frame(minHeight: 80)
|
|
||||||
.padding(8)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.quaternary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Duration (seconds)")
|
|
||||||
Spacer()
|
|
||||||
TextField("0", value: $duration, format: .number)
|
TextField("0", value: $duration, format: .number)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.textFieldStyle(.roundedBorder)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
LabeledContent("Rest Time (seconds)") {
|
||||||
Text("Rest Time (seconds)")
|
|
||||||
Spacer()
|
|
||||||
TextField("0", value: $restTime, format: .number)
|
TextField("0", value: $restTime, format: .number)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.textFieldStyle(.roundedBorder)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,65 +745,14 @@ struct EditAttemptView: View {
|
|||||||
@EnvironmentObject var themeManager: ThemeManager
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var selectedProblem: Problem?
|
|
||||||
@State private var selectedResult: AttemptResult
|
@State private var selectedResult: AttemptResult
|
||||||
@State private var highestHold: String
|
@State private var highestHold: String
|
||||||
@State private var notes: String
|
@State private var notes: String
|
||||||
@State private var duration: Int
|
@State private var duration: Int
|
||||||
@State private var restTime: Int
|
@State private var restTime: Int
|
||||||
@State private var showingCreateProblem = false
|
|
||||||
|
|
||||||
// New problem creation state
|
private var problem: Problem? {
|
||||||
@State private var newProblemName = ""
|
dataManager.problem(withId: attempt.problemId)
|
||||||
@State private var newProblemGrade = ""
|
|
||||||
@State private var selectedClimbType: ClimbType = .boulder
|
|
||||||
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
|
||||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
|
||||||
@State private var imageData: [Data] = []
|
|
||||||
|
|
||||||
enum SheetType: Identifiable {
|
|
||||||
case photoOptions
|
|
||||||
|
|
||||||
var id: Int {
|
|
||||||
switch self {
|
|
||||||
case .photoOptions: return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var activeSheet: SheetType?
|
|
||||||
@State private var showCamera = false
|
|
||||||
@State private var showPhotoPicker = false
|
|
||||||
@State private var isPhotoPickerActionPending = false
|
|
||||||
@State private var isCameraActionPending = false
|
|
||||||
|
|
||||||
private var availableProblems: [Problem] {
|
|
||||||
guard let session = dataManager.session(withId: attempt.sessionId) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return dataManager.problems.filter { $0.isActive && $0.gymId == session.gymId }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gym: Gym? {
|
|
||||||
guard let session = dataManager.session(withId: attempt.sessionId) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return dataManager.gym(withId: session.gymId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableClimbTypes: [ClimbType] {
|
|
||||||
gym?.supportedClimbTypes ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableDifficultySystems: [DifficultySystem] {
|
|
||||||
guard let gym = gym else { return [] }
|
|
||||||
return DifficultySystem.systemsForClimbType(selectedClimbType).filter { system in
|
|
||||||
gym.difficultySystems.contains(system)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var availableGrades: [String] {
|
|
||||||
selectedDifficultySystem.availableGrades
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(attempt: Attempt) {
|
init(attempt: Attempt) {
|
||||||
@@ -834,12 +767,7 @@ struct EditAttemptView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
if !showingCreateProblem {
|
ProblemSection()
|
||||||
ProblemSelectionSection()
|
|
||||||
} else {
|
|
||||||
CreateProblemSection()
|
|
||||||
}
|
|
||||||
|
|
||||||
AttemptDetailsSection()
|
AttemptDetailsSection()
|
||||||
}
|
}
|
||||||
.navigationTitle("Edit Attempt")
|
.navigationTitle("Edit Attempt")
|
||||||
@@ -855,269 +783,35 @@ struct EditAttemptView: View {
|
|||||||
Button("Update") {
|
Button("Update") {
|
||||||
updateAttempt()
|
updateAttempt()
|
||||||
}
|
}
|
||||||
.disabled(!canSave)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
selectedProblem = dataManager.problem(withId: attempt.problemId)
|
|
||||||
setupInitialValues()
|
|
||||||
}
|
|
||||||
.onChange(of: selectedClimbType) {
|
|
||||||
updateDifficultySystem()
|
|
||||||
}
|
|
||||||
.onChange(of: selectedDifficultySystem) {
|
|
||||||
resetGradeIfNeeded()
|
|
||||||
}
|
|
||||||
.onChange(of: selectedPhotos) {
|
|
||||||
Task {
|
|
||||||
await loadSelectedPhotos()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.photosPicker(
|
|
||||||
isPresented: $showPhotoPicker,
|
|
||||||
selection: $selectedPhotos,
|
|
||||||
maxSelectionCount: 5 - imageData.count,
|
|
||||||
matching: .images
|
|
||||||
)
|
|
||||||
.sheet(
|
|
||||||
item: $activeSheet,
|
|
||||||
onDismiss: {
|
|
||||||
if isCameraActionPending {
|
|
||||||
showCamera = true
|
|
||||||
isCameraActionPending = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isPhotoPickerActionPending {
|
|
||||||
showPhotoPicker = true
|
|
||||||
isPhotoPickerActionPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { sheetType in
|
|
||||||
switch sheetType {
|
|
||||||
case .photoOptions:
|
|
||||||
PhotoOptionSheet(
|
|
||||||
selectedPhotos: $selectedPhotos,
|
|
||||||
imageData: $imageData,
|
|
||||||
maxImages: 5,
|
|
||||||
onCameraSelected: {
|
|
||||||
isCameraActionPending = true
|
|
||||||
activeSheet = nil
|
|
||||||
},
|
|
||||||
onPhotoLibrarySelected: {
|
|
||||||
isPhotoPickerActionPending = true
|
|
||||||
},
|
|
||||||
onDismiss: {
|
|
||||||
activeSheet = nil
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $showCamera) {
|
|
||||||
CameraImagePicker { capturedImage in
|
|
||||||
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
|
|
||||||
imageData.append(jpegData)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func ProblemSelectionSection() -> some View {
|
private func ProblemSection() -> some View {
|
||||||
Section("Select Problem") {
|
Section("Problem") {
|
||||||
if availableProblems.isEmpty {
|
if let problem = problem {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("No active problems in this gym")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Button("Create New Problem") {
|
|
||||||
showingCreateProblem = true
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(themeManager.accentColor)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
} else {
|
|
||||||
LazyVGrid(
|
|
||||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
|
||||||
spacing: 8
|
|
||||||
) {
|
|
||||||
ForEach(availableProblems, id: \.id) { problem in
|
|
||||||
ProblemSelectionCard(
|
|
||||||
problem: problem,
|
|
||||||
isSelected: selectedProblem?.id == problem.id
|
|
||||||
) {
|
|
||||||
selectedProblem = problem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
Button("Create New Problem") {
|
|
||||||
showingCreateProblem = true
|
|
||||||
}
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func CreateProblemSection() -> some View {
|
|
||||||
Section {
|
|
||||||
HStack {
|
|
||||||
Text("Create New Problem")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button("Back") {
|
|
||||||
showingCreateProblem = false
|
|
||||||
selectedPhotos = []
|
|
||||||
imageData = []
|
|
||||||
}
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Problem Details") {
|
|
||||||
TextField("Problem Name", text: $newProblemName)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Climb Type") {
|
|
||||||
ForEach(availableClimbTypes, id: \.self) { climbType in
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(climbType.displayName)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Spacer()
|
Text(problem.name ?? "Unnamed Problem")
|
||||||
if selectedClimbType == climbType {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "circle")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedClimbType = climbType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Difficulty") {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Text("Difficulty System")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
ForEach(availableDifficultySystems, id: \.self) { system in
|
|
||||||
HStack {
|
|
||||||
Text(system.displayName)
|
|
||||||
Spacer()
|
|
||||||
if selectedDifficultySystem == system {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "circle")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedDifficultySystem = system
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectedDifficultySystem == .custom {
|
|
||||||
TextField("Grade (Required - numbers only)", text: $newProblemGrade)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.onChange(of: newProblemGrade) {
|
|
||||||
// Filter out non-numeric characters
|
|
||||||
newProblemGrade = newProblemGrade.filter { $0.isNumber }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Grade (Required)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
LazyHStack(spacing: 8) {
|
|
||||||
ForEach(availableGrades, id: \.self) { grade in
|
|
||||||
Button(grade) {
|
|
||||||
newProblemGrade = grade
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
.tint(newProblemGrade == grade ? themeManager.accentColor : .gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Photos (Optional)") {
|
|
||||||
Button(action: {
|
|
||||||
activeSheet = .photoOptions
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "camera.fill")
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
.font(.title2)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Add Photos")
|
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(themeManager.accentColor)
|
HStack(spacing: 8) {
|
||||||
Text("\(imageData.count) of 5 photos added")
|
Text(problem.climbType.displayName)
|
||||||
.font(.caption)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
Text("•")
|
||||||
Spacer()
|
.foregroundColor(.secondary)
|
||||||
Image(systemName: "chevron.right")
|
Text(problem.difficulty.grade)
|
||||||
.foregroundColor(.secondary)
|
.font(.subheadline)
|
||||||
.font(.caption)
|
.foregroundColor(.secondary)
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
.disabled(imageData.count >= 5)
|
|
||||||
|
|
||||||
if !imageData.isEmpty {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ForEach(imageData.indices, id: \.self) { index in
|
|
||||||
if let uiImage = UIImage(data: imageData[index]) {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
.clipped()
|
|
||||||
.cornerRadius(8)
|
|
||||||
.overlay(alignment: .topTrailing) {
|
|
||||||
Button(action: {
|
|
||||||
imageData.remove(at: index)
|
|
||||||
}) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.background(Circle().fill(.white))
|
|
||||||
}
|
|
||||||
.offset(x: 8, y: -8)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.gray.opacity(0.3))
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 1)
|
Spacer()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Text("Problem not found")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1147,158 +841,33 @@ struct EditAttemptView: View {
|
|||||||
Section("Additional Details") {
|
Section("Additional Details") {
|
||||||
TextField("Highest Hold (Optional)", text: $highestHold)
|
TextField("Highest Hold (Optional)", text: $highestHold)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("Notes (Optional)", text: $notes, axis: .vertical)
|
||||||
Text("Notes (Optional)")
|
.lineLimit(3...6)
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
TextEditor(text: $notes)
|
LabeledContent("Duration (seconds)") {
|
||||||
.frame(minHeight: 80)
|
|
||||||
.padding(8)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.quaternary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text("Duration (seconds)")
|
|
||||||
Spacer()
|
|
||||||
TextField("0", value: $duration, format: .number)
|
TextField("0", value: $duration, format: .number)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.textFieldStyle(.roundedBorder)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
LabeledContent("Rest Time (seconds)") {
|
||||||
Text("Rest Time (seconds)")
|
|
||||||
Spacer()
|
|
||||||
TextField("0", value: $restTime, format: .number)
|
TextField("0", value: $restTime, format: .number)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
.textFieldStyle(.roundedBorder)
|
.multilineTextAlignment(.trailing)
|
||||||
.frame(width: 80)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSave: Bool {
|
|
||||||
if showingCreateProblem {
|
|
||||||
return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
} else {
|
|
||||||
return selectedProblem != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupInitialValues() {
|
|
||||||
guard let gym = gym else { return }
|
|
||||||
|
|
||||||
// Auto-select climb type if there's only one available
|
|
||||||
if gym.supportedClimbTypes.count == 1 {
|
|
||||||
selectedClimbType = gym.supportedClimbTypes.first!
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDifficultySystem()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateDifficultySystem() {
|
|
||||||
let available = availableDifficultySystems
|
|
||||||
|
|
||||||
if !available.contains(selectedDifficultySystem) {
|
|
||||||
selectedDifficultySystem = available.first ?? .custom
|
|
||||||
}
|
|
||||||
|
|
||||||
if available.count == 1 {
|
|
||||||
selectedDifficultySystem = available.first!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetGradeIfNeeded() {
|
|
||||||
let availableGrades = selectedDifficultySystem.availableGrades
|
|
||||||
if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
|
|
||||||
newProblemGrade = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadSelectedPhotos() async {
|
|
||||||
var newImageData: [Data] = []
|
|
||||||
|
|
||||||
for item in selectedPhotos {
|
|
||||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
|
||||||
newImageData.append(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
imageData.append(contentsOf: newImageData)
|
|
||||||
selectedPhotos.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateAttempt() {
|
private func updateAttempt() {
|
||||||
if showingCreateProblem {
|
let updatedAttempt = attempt.updated(
|
||||||
guard let gym = gym else { return }
|
result: selectedResult,
|
||||||
|
highestHold: highestHold.isEmpty ? nil : highestHold,
|
||||||
let difficulty = DifficultyGrade(
|
notes: notes.isEmpty ? nil : notes,
|
||||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
duration: duration > 0 ? duration : nil,
|
||||||
|
restTime: restTime > 0 ? restTime : nil
|
||||||
let newProblem = Problem(
|
)
|
||||||
gymId: gym.id,
|
|
||||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
|
||||||
climbType: selectedClimbType,
|
|
||||||
difficulty: difficulty,
|
|
||||||
imagePaths: []
|
|
||||||
)
|
|
||||||
|
|
||||||
dataManager.addProblem(newProblem)
|
|
||||||
|
|
||||||
if !imageData.isEmpty {
|
|
||||||
var imagePaths: [String] = []
|
|
||||||
|
|
||||||
for (index, data) in imageData.enumerated() {
|
|
||||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
|
||||||
problemId: newProblem.id.uuidString, imageIndex: index)
|
|
||||||
|
|
||||||
if let relativePath = ImageManager.shared.saveImageData(
|
|
||||||
data, withName: deterministicName)
|
|
||||||
{
|
|
||||||
imagePaths.append(relativePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !imagePaths.isEmpty {
|
|
||||||
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
|
|
||||||
dataManager.updateProblem(updatedProblem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedAttempt = attempt.updated(
|
|
||||||
problemId: newProblem.id,
|
|
||||||
result: selectedResult,
|
|
||||||
highestHold: highestHold.isEmpty ? nil : highestHold,
|
|
||||||
notes: notes.isEmpty ? nil : notes,
|
|
||||||
duration: duration > 0 ? duration : nil,
|
|
||||||
restTime: restTime > 0 ? restTime : nil
|
|
||||||
)
|
|
||||||
|
|
||||||
dataManager.updateAttempt(updatedAttempt)
|
|
||||||
} else {
|
|
||||||
guard selectedProblem != nil else { return }
|
|
||||||
|
|
||||||
let updatedAttempt = attempt.updated(
|
|
||||||
problemId: selectedProblem?.id,
|
|
||||||
result: selectedResult,
|
|
||||||
highestHold: highestHold.isEmpty ? nil : highestHold,
|
|
||||||
notes: notes.isEmpty ? nil : notes,
|
|
||||||
duration: duration > 0 ? duration : nil,
|
|
||||||
restTime: restTime > 0 ? restTime : nil
|
|
||||||
)
|
|
||||||
|
|
||||||
dataManager.updateAttempt(updatedAttempt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear photo states after saving
|
|
||||||
selectedPhotos = []
|
|
||||||
imageData = []
|
|
||||||
|
|
||||||
|
dataManager.updateAttempt(updatedAttempt)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import PhotosUI
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct AddEditProblemView: View {
|
struct AddEditProblemView: View {
|
||||||
let problemId: UUID?
|
let problemId: UUID?
|
||||||
let gymId: UUID?
|
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@EnvironmentObject var themeManager: ThemeManager
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -11,63 +11,44 @@ struct AddEditProblemView: View {
|
|||||||
@State private var selectedGym: Gym?
|
@State private var selectedGym: Gym?
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var description = ""
|
@State private var description = ""
|
||||||
@State private var selectedClimbType: ClimbType = .boulder
|
@State private var selectedClimbType: ClimbType
|
||||||
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
@State private var selectedDifficultySystem: DifficultySystem
|
||||||
@State private var difficultyGrade = ""
|
@State private var difficultyGrade = ""
|
||||||
|
@State private var availableDifficultySystems: [DifficultySystem] = []
|
||||||
@State private var location = ""
|
@State private var location = ""
|
||||||
@State private var tags = ""
|
@State private var tags = ""
|
||||||
@State private var notes = ""
|
@State private var notes = ""
|
||||||
@State private var isActive = true
|
@State private var isActive = true
|
||||||
@State private var dateSet = Date()
|
@State private var dateSet = Date()
|
||||||
@State private var imagePaths: [String] = []
|
@State private var imagePaths: [String] = []
|
||||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
|
||||||
@State private var imageData: [Data] = []
|
@State private var imageData: [Data] = []
|
||||||
|
@State private var showingPhotoOptions = false
|
||||||
|
@State private var showingCamera = false
|
||||||
|
@State private var showingImagePicker = false
|
||||||
|
@State private var imageSource: UIImagePickerController.SourceType = .photoLibrary
|
||||||
@State private var isEditing = false
|
@State private var isEditing = false
|
||||||
enum SheetType: Identifiable {
|
@State private var showingGradeError = false
|
||||||
case photoOptions
|
@State private var isLoaded = false
|
||||||
|
@State private var originalImages: [(path: String, data: Data)] = []
|
||||||
var id: Int {
|
|
||||||
switch self {
|
|
||||||
case .photoOptions: return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@State private var activeSheet: SheetType?
|
|
||||||
@State private var showCamera = false
|
|
||||||
@State private var showPhotoPicker = false
|
|
||||||
@State private var isPhotoPickerActionPending = false
|
|
||||||
@State private var isCameraActionPending = false
|
|
||||||
|
|
||||||
private var existingProblem: Problem? {
|
private var existingProblem: Problem? {
|
||||||
guard let problemId = problemId else { return nil }
|
guard let problemId = problemId else { return nil }
|
||||||
return dataManager.problem(withId: problemId)
|
return dataManager.problem(withId: problemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableClimbTypes: [ClimbType] {
|
private var existingProblemGym: Gym? {
|
||||||
selectedGym?.supportedClimbTypes ?? ClimbType.allCases
|
guard let problem = existingProblem else { return nil }
|
||||||
|
return dataManager.gym(withId: problem.gymId)
|
||||||
}
|
}
|
||||||
|
|
||||||
var availableDifficultySystems: [DifficultySystem] {
|
private var gymId: UUID? {
|
||||||
guard let gym = selectedGym else {
|
return selectedGym?.id ?? existingProblemGym?.id
|
||||||
return DifficultySystem.systemsForClimbType(selectedClimbType)
|
|
||||||
}
|
|
||||||
|
|
||||||
let compatibleSystems = DifficultySystem.systemsForClimbType(selectedClimbType)
|
|
||||||
let gymSupportedSystems = gym.difficultySystems.filter { system in
|
|
||||||
compatibleSystems.contains(system)
|
|
||||||
}
|
|
||||||
|
|
||||||
return gymSupportedSystems.isEmpty ? compatibleSystems : gymSupportedSystems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var availableGrades: [String] {
|
init(problemId: UUID? = nil) {
|
||||||
selectedDifficultySystem.availableGrades
|
|
||||||
}
|
|
||||||
|
|
||||||
init(problemId: UUID? = nil, gymId: UUID? = nil) {
|
|
||||||
self.problemId = problemId
|
self.problemId = problemId
|
||||||
self.gymId = gymId
|
self._selectedClimbType = State(initialValue: .boulder)
|
||||||
|
self._selectedDifficultySystem = State(initialValue: .vScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -78,11 +59,10 @@ struct AddEditProblemView: View {
|
|||||||
PhotosSection()
|
PhotosSection()
|
||||||
ClimbTypeSection()
|
ClimbTypeSection()
|
||||||
DifficultySection()
|
DifficultySection()
|
||||||
LocationSection()
|
LocationDetailsSection()
|
||||||
TagsSection()
|
|
||||||
AdditionalInfoSection()
|
AdditionalInfoSection()
|
||||||
}
|
}
|
||||||
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
|
.navigationTitle(isEditing ? "Edit Problem" : "New Problem")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
@@ -93,80 +73,55 @@ struct AddEditProblemView: View {
|
|||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
saveProblem()
|
if canSave {
|
||||||
|
saveProblem()
|
||||||
|
} else {
|
||||||
|
showingGradeError = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(!canSave)
|
.disabled(!canSave && !showingGradeError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
setupInitialClimbType()
|
||||||
loadExistingProblem()
|
loadExistingProblem()
|
||||||
setupInitialGym()
|
DispatchQueue.main.async {
|
||||||
}
|
isLoaded = true
|
||||||
.onChange(of: dataManager.gyms) {
|
|
||||||
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
|
||||||
selectedGym = dataManager.gyms.first
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedGym) {
|
.sheet(isPresented: $showingPhotoOptions) {
|
||||||
updateAvailableOptions()
|
PhotoOptionSheet(
|
||||||
}
|
selectedPhotos: .constant([]),
|
||||||
.onChange(of: selectedClimbType) {
|
imageData: $imageData,
|
||||||
updateDifficultySystem()
|
maxImages: 5,
|
||||||
}
|
onCameraSelected: {
|
||||||
.onChange(of: selectedDifficultySystem) {
|
showingCamera = true
|
||||||
resetGradeIfNeeded()
|
},
|
||||||
}
|
onPhotoLibrarySelected: {
|
||||||
.sheet(
|
showingImagePicker = true
|
||||||
item: $activeSheet,
|
},
|
||||||
onDismiss: {
|
onDismiss: {
|
||||||
if isCameraActionPending {
|
showingPhotoOptions = false
|
||||||
showCamera = true
|
|
||||||
isCameraActionPending = false
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if isPhotoPickerActionPending {
|
)
|
||||||
showPhotoPicker = true
|
|
||||||
isPhotoPickerActionPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { sheetType in
|
|
||||||
switch sheetType {
|
|
||||||
case .photoOptions:
|
|
||||||
PhotoOptionSheet(
|
|
||||||
selectedPhotos: $selectedPhotos,
|
|
||||||
imageData: $imageData,
|
|
||||||
maxImages: 5,
|
|
||||||
onCameraSelected: {
|
|
||||||
isCameraActionPending = true
|
|
||||||
activeSheet = nil
|
|
||||||
},
|
|
||||||
onPhotoLibrarySelected: {
|
|
||||||
isPhotoPickerActionPending = true
|
|
||||||
},
|
|
||||||
onDismiss: {
|
|
||||||
activeSheet = nil
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showCamera) {
|
.sheet(isPresented: $showingCamera) {
|
||||||
CameraImagePicker { capturedImage in
|
CameraImagePicker { image in
|
||||||
if let jpegData = capturedImage.jpegData(compressionQuality: 0.8) {
|
if let data = image.jpegData(compressionQuality: 0.8) {
|
||||||
imageData.append(jpegData)
|
imageData.append(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.photosPicker(
|
.sheet(isPresented: $showingImagePicker) {
|
||||||
isPresented: $showPhotoPicker,
|
ImagePicker(
|
||||||
selection: $selectedPhotos,
|
selectedImages: Binding(
|
||||||
maxSelectionCount: 5 - imageData.count,
|
get: { imageData },
|
||||||
matching: .images
|
set: { imageData = $0 }
|
||||||
)
|
),
|
||||||
.onChange(of: selectedPhotos) {
|
sourceType: imageSource,
|
||||||
Task {
|
selectionLimit: 5
|
||||||
await loadSelectedPhotos()
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,75 +133,123 @@ struct AddEditProblemView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(dataManager.gyms, id: \.id) { gym in
|
ForEach(dataManager.gyms, id: \.id) { gym in
|
||||||
HStack {
|
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
.onTapGesture {
|
||||||
Text(gym.name)
|
selectedGym = gym
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if let location = gym.location, !location.isEmpty {
|
|
||||||
Text(location)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if selectedGym?.id == gym.id {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedGym = gym
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func gymRow(gym: Gym, isSelected: Bool) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(gym.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let location = gym.location, !location.isEmpty {
|
||||||
|
Text(location)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(themeManager.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func BasicInfoSection() -> some View {
|
private func BasicInfoSection() -> some View {
|
||||||
Section("Problem Details") {
|
Section("Problem Details") {
|
||||||
TextField("Problem Name (Optional)", text: $name)
|
TextField("Problem Name (Optional)", text: $name)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("Description (Optional)", text: $description, axis: .vertical)
|
||||||
Text("Description (Optional)")
|
.lineLimit(3...6)
|
||||||
.font(.headline)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TextEditor(text: $description)
|
@ViewBuilder
|
||||||
.frame(minHeight: 80)
|
private func PhotosSection() -> some View {
|
||||||
.padding(8)
|
Section("Photos (Optional)") {
|
||||||
.background(
|
Button(action: {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
showingPhotoOptions = true
|
||||||
.fill(.quaternary)
|
}) {
|
||||||
)
|
HStack {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.foregroundColor(themeManager.accentColor)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Add Photos")
|
||||||
|
.foregroundColor(themeManager.accentColor)
|
||||||
|
Text("\(imageData.count) of 5 photos added")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !imageData.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(imageData.indices, id: \.self) { index in
|
||||||
|
if let uiImage = UIImage(data: imageData[index]) {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(uiImage: uiImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
imageData.remove(at: index)
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.background(Circle().fill(Color.black.opacity(0.5)))
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func ClimbTypeSection() -> some View {
|
private func ClimbTypeSection() -> some View {
|
||||||
if selectedGym != nil {
|
Section("Climb Type") {
|
||||||
Section("Climb Type") {
|
ForEach(ClimbType.allCases, id: \.self) { type in
|
||||||
ForEach(availableClimbTypes, id: \.self) { climbType in
|
HStack {
|
||||||
HStack {
|
Text(type.displayName)
|
||||||
Text(climbType.displayName)
|
Spacer()
|
||||||
Spacer()
|
if selectedClimbType == type {
|
||||||
if selectedClimbType == climbType {
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Image(systemName: "checkmark.circle.fill")
|
.foregroundColor(themeManager.accentColor)
|
||||||
.foregroundColor(themeManager.accentColor)
|
} else {
|
||||||
} else {
|
Image(systemName: "circle")
|
||||||
Image(systemName: "circle")
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedClimbType = climbType
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
selectedClimbType = type
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,220 +257,78 @@ struct AddEditProblemView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func DifficultySection() -> some View {
|
private func DifficultySection() -> some View {
|
||||||
Section("Difficulty") {
|
Section("Difficulty") {
|
||||||
// Difficulty System
|
Picker("Difficulty System", selection: $selectedDifficultySystem) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
ForEach(DifficultySystem.systemsForClimbType(selectedClimbType), id: \.self) { system in
|
||||||
Text("Difficulty System")
|
Text(system.displayName).tag(system)
|
||||||
.font(.headline)
|
}
|
||||||
|
}
|
||||||
ForEach(availableDifficultySystems, id: \.self) { system in
|
.onChange(of: selectedDifficultySystem) { oldValue, newValue in
|
||||||
HStack {
|
if isLoaded {
|
||||||
Text(system.displayName)
|
difficultyGrade = ""
|
||||||
Spacer()
|
|
||||||
if selectedDifficultySystem == system {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "circle")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedDifficultySystem = system
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grade Selection
|
if selectedDifficultySystem == .custom {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Grade (Required)")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if selectedDifficultySystem == .custom || availableGrades.isEmpty {
|
|
||||||
TextField("Enter custom grade (numbers only)", text: $difficultyGrade)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.keyboardType(.numberPad)
|
|
||||||
.onChange(of: difficultyGrade) {
|
|
||||||
// Filter out non-numeric characters
|
|
||||||
difficultyGrade = difficultyGrade.filter { $0.isNumber }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Menu {
|
|
||||||
if !difficultyGrade.isEmpty {
|
|
||||||
Button("Clear Selection") {
|
|
||||||
difficultyGrade = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(availableGrades, id: \.self) { grade in
|
|
||||||
Button(grade) {
|
|
||||||
difficultyGrade = grade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(difficultyGrade.isEmpty ? "Select Grade" : difficultyGrade)
|
|
||||||
.foregroundColor(difficultyGrade.isEmpty ? .secondary : .primary)
|
|
||||||
.fontWeight(difficultyGrade.isEmpty ? .regular : .semibold)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.gray.opacity(0.1))
|
|
||||||
.stroke(
|
|
||||||
difficultyGrade.isEmpty
|
|
||||||
? .red.opacity(0.5) : .gray.opacity(0.3), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
if difficultyGrade.isEmpty {
|
|
||||||
Text("Please select a grade to continue")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.italic()
|
|
||||||
} else {
|
|
||||||
Text("Selected: \(difficultyGrade)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func LocationSection() -> some View {
|
|
||||||
Section("Location & Details") {
|
|
||||||
TextField(
|
|
||||||
"Location (Optional)", text: $location, prompt: Text("e.g., 'Cave area', 'Wall 3'"))
|
|
||||||
|
|
||||||
DatePicker(
|
|
||||||
"Date Set",
|
|
||||||
selection: $dateSet,
|
|
||||||
displayedComponents: [.date]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func TagsSection() -> some View {
|
|
||||||
Section("Tags (Optional)") {
|
|
||||||
TextField("Tags", text: $tags, prompt: Text("e.g., crimpy, dynamic (comma-separated)"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func PhotosSection() -> some View {
|
|
||||||
Section("Photos (Optional)") {
|
|
||||||
Button(action: {
|
|
||||||
activeSheet = .photoOptions
|
|
||||||
}) {
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "camera.fill")
|
Text("Grade")
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
.font(.title2)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Add Photos")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
Text("\(imageData.count) of 5 photos added")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
TextField("Numbers only", text: $difficultyGrade)
|
||||||
.foregroundColor(.secondary)
|
.multilineTextAlignment(.trailing)
|
||||||
.font(.caption)
|
.keyboardType(.numberPad)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
} else {
|
||||||
}
|
Picker("Grade", selection: $difficultyGrade) {
|
||||||
.disabled(imageData.count >= 5)
|
if difficultyGrade.isEmpty {
|
||||||
|
Text("Select Grade").tag("")
|
||||||
if !imageData.isEmpty {
|
}
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ForEach(selectedDifficultySystem.availableGrades, id: \.self) { grade in
|
||||||
HStack(spacing: 12) {
|
Text(grade).tag(grade)
|
||||||
ForEach(imageData.indices, id: \.self) { index in
|
|
||||||
if let uiImage = UIImage(data: imageData[index]) {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
.clipped()
|
|
||||||
.cornerRadius(8)
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
imageData.remove(at: index)
|
|
||||||
if index < imagePaths.count {
|
|
||||||
imagePaths.remove(at: index)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.background(Circle().fill(.white))
|
|
||||||
.font(.system(size: 18))
|
|
||||||
}
|
|
||||||
.offset(x: 4, y: -4)
|
|
||||||
}
|
|
||||||
.frame(width: 88, height: 88) // Extra space for button
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.gray.opacity(0.3))
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "photo")
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 1)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if showingGradeError && difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
Text("Please select a grade to continue")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.italic()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func LocationDetailsSection() -> some View {
|
||||||
|
Section("Location & Details") {
|
||||||
|
TextField("e.g., 'Cave area', 'Wall 3'", text: $location)
|
||||||
|
|
||||||
|
DatePicker("Date Set", selection: $dateSet, displayedComponents: .date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func AdditionalInfoSection() -> some View {
|
private func AdditionalInfoSection() -> some View {
|
||||||
Section("Additional Information") {
|
Section("Tags (Optional)") {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
TextField("e.g., crimpy, dynamic (comma-separated)", text: $tags)
|
||||||
Text("Notes (Optional)")
|
}
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
TextEditor(text: $notes)
|
Section("Additional Information") {
|
||||||
.frame(minHeight: 80)
|
TextField("Notes (Optional)", text: $notes, axis: .vertical)
|
||||||
.padding(8)
|
.lineLimit(3...6)
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.quaternary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("Problem is currently active", isOn: $isActive)
|
Toggle("Problem is currently active", isOn: $isActive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
selectedGym != nil
|
selectedGym != nil && !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
&& !difficultyGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupInitialGym() {
|
private func setupInitialClimbType() {
|
||||||
if let gymId = gymId {
|
if let gymId = gymId {
|
||||||
selectedGym = dataManager.gym(withId: gymId)
|
selectedGym = dataManager.gym(withId: gymId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always ensure a gym is selected if available and none is currently selected
|
|
||||||
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
if selectedGym == nil && !dataManager.gyms.isEmpty {
|
||||||
selectedGym = dataManager.gyms.first
|
selectedGym = dataManager.gyms.first
|
||||||
}
|
}
|
||||||
@@ -487,147 +348,84 @@ struct AddEditProblemView: View {
|
|||||||
tags = problem.tags.joined(separator: ", ")
|
tags = problem.tags.joined(separator: ", ")
|
||||||
notes = problem.notes ?? ""
|
notes = problem.notes ?? ""
|
||||||
isActive = problem.isActive
|
isActive = problem.isActive
|
||||||
|
if let date = problem.dateSet {
|
||||||
|
dateSet = date
|
||||||
|
}
|
||||||
imagePaths = problem.imagePaths
|
imagePaths = problem.imagePaths
|
||||||
|
|
||||||
// Load image data for preview
|
|
||||||
imageData = []
|
imageData = []
|
||||||
|
originalImages = []
|
||||||
for imagePath in problem.imagePaths {
|
for imagePath in problem.imagePaths {
|
||||||
if let data = ImageManager.shared.loadImageData(fromPath: imagePath) {
|
if let data = ImageManager.shared.loadImageData(fromPath: imagePath) {
|
||||||
imageData.append(data)
|
imageData.append(data)
|
||||||
|
originalImages.append((path: imagePath, data: data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let dateSet = problem.dateSet {
|
|
||||||
self.dateSet = dateSet
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAvailableOptions() {
|
|
||||||
guard let gym = selectedGym else { return }
|
|
||||||
|
|
||||||
// Auto-select climb type if there's only one available
|
|
||||||
if gym.supportedClimbTypes.count == 1, selectedClimbType != gym.supportedClimbTypes.first! {
|
|
||||||
selectedClimbType = gym.supportedClimbTypes.first!
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDifficultySystem()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateDifficultySystem() {
|
|
||||||
let available = availableDifficultySystems
|
|
||||||
|
|
||||||
if !available.contains(selectedDifficultySystem) {
|
|
||||||
selectedDifficultySystem = available.first ?? .custom
|
|
||||||
}
|
|
||||||
|
|
||||||
if available.count == 1, selectedDifficultySystem != available.first! {
|
|
||||||
selectedDifficultySystem = available.first!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetGradeIfNeeded() {
|
|
||||||
let availableGrades = selectedDifficultySystem.availableGrades
|
|
||||||
if !availableGrades.isEmpty && !availableGrades.contains(difficultyGrade) {
|
|
||||||
difficultyGrade = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadSelectedPhotos() async {
|
|
||||||
for item in selectedPhotos {
|
|
||||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
|
||||||
imageData.append(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectedPhotos.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveProblem() {
|
private func saveProblem() {
|
||||||
guard let gym = selectedGym, canSave else { return }
|
guard let gym = selectedGym else { return }
|
||||||
|
|
||||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedLocation = location.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let trimmedTags = tags.split(separator: ",").map {
|
let trimmedTags = tags.split(separator: ",").map {
|
||||||
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
}.filter { !$0.isEmpty }
|
}.filter { !$0.isEmpty }
|
||||||
|
|
||||||
let difficulty = DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade)
|
var finalPaths: [String] = []
|
||||||
|
var preservedPaths: Set<String> = []
|
||||||
|
|
||||||
if isEditing, let problem = existingProblem {
|
for data in imageData {
|
||||||
var allImagePaths = imagePaths
|
if let existing = originalImages.first(where: { $0.data == data }) {
|
||||||
|
finalPaths.append(existing.path)
|
||||||
let newImagesStartIndex = imagePaths.count
|
preservedPaths.insert(existing.path)
|
||||||
if imageData.count > newImagesStartIndex {
|
} else {
|
||||||
for i in newImagesStartIndex..<imageData.count {
|
if let newPath = ImageManager.shared.saveImageData(data) {
|
||||||
let data = imageData[i]
|
finalPaths.append(newPath)
|
||||||
let imageIndex = allImagePaths.count
|
|
||||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
|
||||||
problemId: problem.id.uuidString, imageIndex: imageIndex)
|
|
||||||
|
|
||||||
if let relativePath = ImageManager.shared.saveImageData(
|
|
||||||
data, withName: deterministicName)
|
|
||||||
{
|
|
||||||
allImagePaths.append(relativePath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for original in originalImages {
|
||||||
|
if !preservedPaths.contains(original.path) {
|
||||||
|
_ = ImageManager.shared.deleteImage(atPath: original.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEditing, let problem = existingProblem {
|
||||||
let updatedProblem = problem.updated(
|
let updatedProblem = problem.updated(
|
||||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||||
climbType: selectedClimbType,
|
climbType: selectedClimbType,
|
||||||
difficulty: difficulty,
|
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
|
||||||
|
|
||||||
tags: trimmedTags,
|
tags: trimmedTags,
|
||||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||||
imagePaths: allImagePaths,
|
imagePaths: finalPaths,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
dateSet: dateSet,
|
dateSet: dateSet,
|
||||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||||
)
|
)
|
||||||
dataManager.updateProblem(updatedProblem)
|
dataManager.updateProblem(updatedProblem)
|
||||||
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
let newProblem = Problem(
|
let problem = Problem(
|
||||||
gymId: gym.id,
|
gymId: gym.id,
|
||||||
name: trimmedName.isEmpty ? nil : trimmedName,
|
name: trimmedName.isEmpty ? nil : trimmedName,
|
||||||
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
description: trimmedDescription.isEmpty ? nil : trimmedDescription,
|
||||||
climbType: selectedClimbType,
|
climbType: selectedClimbType,
|
||||||
difficulty: difficulty,
|
difficulty: DifficultyGrade(system: selectedDifficultySystem, grade: difficultyGrade),
|
||||||
|
|
||||||
tags: trimmedTags,
|
tags: trimmedTags,
|
||||||
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
location: trimmedLocation.isEmpty ? nil : trimmedLocation,
|
||||||
imagePaths: [],
|
imagePaths: finalPaths,
|
||||||
dateSet: dateSet,
|
dateSet: dateSet,
|
||||||
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
notes: trimmedNotes.isEmpty ? nil : trimmedNotes
|
||||||
)
|
)
|
||||||
|
dataManager.addProblem(problem)
|
||||||
dataManager.addProblem(newProblem)
|
dismiss()
|
||||||
|
|
||||||
if !imageData.isEmpty {
|
|
||||||
var imagePaths: [String] = []
|
|
||||||
|
|
||||||
for (index, data) in imageData.enumerated() {
|
|
||||||
let deterministicName = ImageNamingUtils.generateImageFilename(
|
|
||||||
problemId: newProblem.id.uuidString, imageIndex: index)
|
|
||||||
|
|
||||||
if let relativePath = ImageManager.shared.saveImageData(
|
|
||||||
data, withName: deterministicName)
|
|
||||||
{
|
|
||||||
imagePaths.append(relativePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !imagePaths.isEmpty {
|
|
||||||
let updatedProblem = newProblem.updated(imagePaths: imagePaths)
|
|
||||||
dataManager.updateProblem(updatedProblem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,34 +56,38 @@ struct AddEditSessionView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(dataManager.gyms, id: \.id) { gym in
|
ForEach(dataManager.gyms, id: \.id) { gym in
|
||||||
HStack {
|
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
.onTapGesture {
|
||||||
Text(gym.name)
|
selectedGym = gym
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if let location = gym.location, !location.isEmpty {
|
|
||||||
Text(location)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if selectedGym?.id == gym.id {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(themeManager.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
selectedGym = gym
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func gymRow(gym: Gym, isSelected: Bool) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(gym.name)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let location = gym.location, !location.isEmpty {
|
||||||
|
Text(location)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(themeManager.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func SessionDetailsSection() -> some View {
|
private func SessionDetailsSection() -> some View {
|
||||||
Section("Session Details") {
|
Section("Session Details") {
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ struct CalendarView: View {
|
|||||||
if let activeSession = dataManager.activeSession,
|
if let activeSession = dataManager.activeSession,
|
||||||
let gym = dataManager.gym(withId: activeSession.gymId)
|
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||||
{
|
{
|
||||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
ActiveSessionBanner(session: activeSession, gym: gym, onNavigateToSession: onNavigateToSession)
|
||||||
.environmentObject(MusicService.shared)
|
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import MusicKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SessionDetailView: View {
|
struct SessionDetailView: View {
|
||||||
let sessionId: UUID
|
let sessionId: UUID
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@EnvironmentObject var themeManager: ThemeManager
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
@EnvironmentObject var musicService: MusicService
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingDeleteAlert = false
|
@State private var showingDeleteAlert = false
|
||||||
@State private var showingAddAttempt = false
|
@State private var showingAddAttempt = false
|
||||||
@@ -47,12 +45,7 @@ struct SessionDetailView: View {
|
|||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
|
||||||
if session.status == .active && musicService.isMusicEnabled && musicService.isAuthorized {
|
|
||||||
MusicControlCard()
|
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionStatsCard(stats: sessionStats)
|
SessionStatsCard(stats: sessionStats)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
|
||||||
@@ -449,52 +442,7 @@ struct SessionStats {
|
|||||||
let uniqueProblemsCompleted: Int
|
let uniqueProblemsCompleted: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MusicControlCard: View {
|
|
||||||
@EnvironmentObject var musicService: MusicService
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "music.note")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(.pink)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.background(Color.pink.opacity(0.1))
|
|
||||||
.clipShape(Circle())
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Music")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if let playlistId = musicService.selectedPlaylistId,
|
|
||||||
let playlist = musicService.playlists.first(where: { $0.id.rawValue == playlistId }) {
|
|
||||||
Text(playlist.name)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("No playlist selected")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
musicService.togglePlayback()
|
|
||||||
}) {
|
|
||||||
Image(systemName: musicService.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
|
||||||
.font(.system(size: 32))
|
|
||||||
.foregroundColor(.pink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 16)
|
|
||||||
.fill(Color(.secondarySystemGroupedBackground))
|
|
||||||
.shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ struct SessionsView: View {
|
|||||||
EmptySessionsView()
|
EmptySessionsView()
|
||||||
} else {
|
} else {
|
||||||
if viewMode == .list {
|
if viewMode == .list {
|
||||||
SessionsList()
|
SessionsList(onNavigateToSession: { sessionId in
|
||||||
|
selectedSessionId = sessionId
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
CalendarView(
|
CalendarView(
|
||||||
sessions: completedSessions,
|
sessions: completedSessions,
|
||||||
@@ -108,6 +110,7 @@ struct SessionsView: View {
|
|||||||
struct SessionsList: View {
|
struct SessionsList: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var sessionToDelete: ClimbSession?
|
@State private var sessionToDelete: ClimbSession?
|
||||||
|
var onNavigateToSession: (UUID) -> Void
|
||||||
|
|
||||||
private var completedSessions: [ClimbSession] {
|
private var completedSessions: [ClimbSession] {
|
||||||
dataManager.sessions
|
dataManager.sessions
|
||||||
@@ -121,8 +124,11 @@ struct SessionsList: View {
|
|||||||
let gym = dataManager.gym(withId: activeSession.gymId)
|
let gym = dataManager.gym(withId: activeSession.gymId)
|
||||||
{
|
{
|
||||||
Section {
|
Section {
|
||||||
ActiveSessionBanner(session: activeSession, gym: gym)
|
ActiveSessionBanner(
|
||||||
.environmentObject(MusicService.shared)
|
session: activeSession,
|
||||||
|
gym: gym,
|
||||||
|
onNavigateToSession: onNavigateToSession
|
||||||
|
)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
|
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -184,8 +190,7 @@ struct ActiveSessionBanner: View {
|
|||||||
let session: ClimbSession
|
let session: ClimbSession
|
||||||
let gym: Gym
|
let gym: Gym
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@EnvironmentObject var musicService: MusicService
|
var onNavigateToSession: (UUID) -> Void
|
||||||
@State private var navigateToDetail = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -210,23 +215,12 @@ struct ActiveSessionBanner: View {
|
|||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
|
|
||||||
if musicService.isMusicEnabled && musicService.isAuthorized {
|
|
||||||
Button(action: {
|
|
||||||
musicService.togglePlayback()
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: musicService.isPlaying ? "pause.fill" : "play.fill")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.foregroundColor(.pink)
|
|
||||||
.padding(.top, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
navigateToDetail = true
|
onNavigateToSession(session.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -249,9 +243,7 @@ struct ActiveSessionBanner: View {
|
|||||||
.fill(.green.opacity(0.1))
|
.fill(.green.opacity(0.1))
|
||||||
.stroke(.green.opacity(0.3), lineWidth: 1)
|
.stroke(.green.opacity(0.3), lineWidth: 1)
|
||||||
)
|
)
|
||||||
.navigationDestination(isPresented: $navigateToDetail) {
|
|
||||||
SessionDetailView(sessionId: session.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
90
ios/Ascently/Views/Settings/AppearanceView.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AppearanceView: View {
|
||||||
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
|
|
||||||
|
let columns = [
|
||||||
|
GridItem(.adaptive(minimum: 44))
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Appearance") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Accent Color")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
LazyVGrid(columns: columns, spacing: 12) {
|
||||||
|
ForEach(ThemeManager.presetColors, id: \.self) { color in
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.overlay(
|
||||||
|
ZStack {
|
||||||
|
if isSelected(color) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.shadow(radius: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
themeManager.accentColor = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(colorDescription(for: color))
|
||||||
|
.accessibilityAddTraits(isSelected(color) ? .isSelected : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSelected(.blue) {
|
||||||
|
Button("Reset to Default") {
|
||||||
|
withAnimation {
|
||||||
|
themeManager.resetToDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Appearance")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSelected(_ color: Color) -> Bool {
|
||||||
|
let selectedUIColor = UIColor(themeManager.accentColor)
|
||||||
|
let targetUIColor = UIColor(color)
|
||||||
|
|
||||||
|
return selectedUIColor == targetUIColor
|
||||||
|
}
|
||||||
|
|
||||||
|
private func colorDescription(for color: Color) -> String {
|
||||||
|
switch color {
|
||||||
|
case .blue: return "Blue"
|
||||||
|
case .purple: return "Purple"
|
||||||
|
case .pink: return "Pink"
|
||||||
|
case .red: return "Red"
|
||||||
|
case .orange: return "Orange"
|
||||||
|
case .green: return "Green"
|
||||||
|
case .teal: return "Teal"
|
||||||
|
case .indigo: return "Indigo"
|
||||||
|
case .mint: return "Mint"
|
||||||
|
case Color(uiColor: .systemBrown): return "Brown"
|
||||||
|
case Color(uiColor: .systemCyan): return "Cyan"
|
||||||
|
default: return "Color"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationView {
|
||||||
|
AppearanceView()
|
||||||
|
.environmentObject(ThemeManager())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import HealthKit
|
import HealthKit
|
||||||
import MusicKit
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
enum SheetType {
|
enum SheetType {
|
||||||
@@ -23,11 +23,12 @@ struct SettingsView: View {
|
|||||||
HealthKitSection()
|
HealthKitSection()
|
||||||
.environmentObject(dataManager.healthKitService)
|
.environmentObject(dataManager.healthKitService)
|
||||||
|
|
||||||
MusicSection()
|
|
||||||
.environmentObject(dataManager.musicService)
|
|
||||||
|
|
||||||
AppearanceSection()
|
AppearanceSection()
|
||||||
|
|
||||||
|
AppIconSection()
|
||||||
|
|
||||||
DataManagementSection(
|
DataManagementSection(
|
||||||
activeSheet: $activeSheet
|
activeSheet: $activeSheet
|
||||||
)
|
)
|
||||||
@@ -168,6 +169,171 @@ struct AppearanceSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppIconSection: View {
|
||||||
|
@State private var currentIcon: String? = UIApplication.shared.alternateIconName
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("App Icon") {
|
||||||
|
Button(action: {
|
||||||
|
setIcon(nil)
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Peaks")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Balls")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Balls")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Balls" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Loss")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Loss")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Loss" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Pride")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Pride" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Trans")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Trans Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Trans" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Enby")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Non-Binary Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Enby" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Bi")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Bisexual Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Bi" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Lesbian")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Lesbian Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Lesbian" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Pan")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Pansexual Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Pan" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
setIcon("Gay")
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Gay Pride")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if currentIcon == "Gay" {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
currentIcon = UIApplication.shared.alternateIconName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setIcon(_ name: String?) {
|
||||||
|
guard UIApplication.shared.alternateIconName != name else { return }
|
||||||
|
|
||||||
|
UIApplication.shared.setAlternateIconName(name) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("Error setting icon: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
currentIcon = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct DataManagementSection: View {
|
struct DataManagementSection: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@EnvironmentObject var themeManager: ThemeManager
|
@EnvironmentObject var themeManager: ThemeManager
|
||||||
@@ -516,8 +682,8 @@ struct ExportDataView: View {
|
|||||||
|
|
||||||
private func cleanupTempFile() {
|
private func cleanupTempFile() {
|
||||||
if let fileURL = tempFileURL {
|
if let fileURL = tempFileURL {
|
||||||
let logTag = Self.logTag // Capture before entering async closure
|
let logTag = Self.logTag
|
||||||
// Clean up after a delay to ensure sharing is complete
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
AppLogger.debug(
|
AppLogger.debug(
|
||||||
@@ -586,80 +752,84 @@ struct SyncSection: View {
|
|||||||
}
|
}
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
if syncService.isConfigured {
|
// Sync Now - show with proper opacity when not available
|
||||||
|
Button(action: {
|
||||||
// Sync Now - only show if connected
|
|
||||||
if syncService.isConnected {
|
if syncService.isConnected {
|
||||||
Button(action: {
|
performSync()
|
||||||
performSync()
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
if syncService.isSyncing {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.8)
|
|
||||||
Text("Syncing...")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
|
||||||
.foregroundColor(.green)
|
|
||||||
Text("Sync Now")
|
|
||||||
Spacer()
|
|
||||||
if let lastSync = syncService.lastSyncTime {
|
|
||||||
Text(
|
|
||||||
RelativeDateTimeFormatter().localizedString(
|
|
||||||
for: lastSync, relativeTo: Date())
|
|
||||||
)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(syncService.isSyncing)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
}
|
||||||
|
}) {
|
||||||
// Auto-sync configuration - always visible for testing
|
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
if syncService.isSyncing {
|
||||||
Text("Auto-sync")
|
ProgressView()
|
||||||
Text("Sync automatically on app launch and data changes")
|
.scaleEffect(0.8)
|
||||||
|
Text("Syncing...")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundColor(syncService.isConnected ? .green : .secondary)
|
||||||
|
Text("Sync Now")
|
||||||
|
.foregroundColor(syncService.isConnected ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
if let lastSync = syncService.lastSyncTime {
|
||||||
|
Text(
|
||||||
|
RelativeDateTimeFormatter().localizedString(
|
||||||
|
for: lastSync, relativeTo: Date())
|
||||||
|
)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle(
|
|
||||||
"",
|
|
||||||
isOn: Binding(
|
|
||||||
get: { syncService.isAutoSyncEnabled },
|
|
||||||
set: { syncService.isAutoSyncEnabled = $0 }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.disabled(!syncService.isConnected)
|
|
||||||
}
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
|
|
||||||
// Disconnect option - only show if connected
|
|
||||||
if syncService.isConnected {
|
|
||||||
Button(action: {
|
|
||||||
showingDisconnectAlert = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "power")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text("Disconnect")
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.disabled(!syncService.isConnected || syncService.isSyncing)
|
||||||
|
.opacity(syncService.isConfigured ? 1.0 : 0.6)
|
||||||
|
|
||||||
if let error = syncService.syncError {
|
// Auto-sync configuration
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Auto-sync")
|
||||||
|
Text("Sync automatically on app launch and data changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle(
|
||||||
|
"",
|
||||||
|
isOn: Binding(
|
||||||
|
get: { syncService.isAutoSyncEnabled },
|
||||||
|
set: { syncService.isAutoSyncEnabled = $0 }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.disabled(!syncService.isConnected)
|
||||||
|
}
|
||||||
|
.opacity(syncService.isConfigured ? 1.0 : 0.6)
|
||||||
|
|
||||||
|
// Disconnect option
|
||||||
|
Button(action: {
|
||||||
|
if syncService.isConnected {
|
||||||
|
showingDisconnectAlert = true
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "power")
|
||||||
|
.foregroundColor(syncService.isConnected ? .orange : .secondary)
|
||||||
|
Text("Disconnect")
|
||||||
|
.foregroundColor(syncService.isConnected ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!syncService.isConnected)
|
||||||
|
.opacity(syncService.isConfigured ? 1.0 : 0.6)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if let error = syncService.syncError {
|
||||||
|
HStack {
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding(.leading, 24)
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 24)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
|
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
|
||||||
@@ -715,12 +885,12 @@ struct SyncSettingsView: View {
|
|||||||
|
|
||||||
if selectedProvider == .server {
|
if selectedProvider == .server {
|
||||||
Section {
|
Section {
|
||||||
TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
|
TextField("Server URL", text: $serverURL)
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
|
|
||||||
TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token"))
|
TextField("Auth Token", text: $authToken)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
} header: {
|
} header: {
|
||||||
@@ -1049,34 +1219,32 @@ struct HealthKitSection: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Toggle(
|
Toggle(isOn: Binding(
|
||||||
isOn: Binding(
|
get: { healthKitService.isEnabled },
|
||||||
get: { healthKitService.isEnabled },
|
set: { newValue in
|
||||||
set: { newValue in
|
if newValue && !healthKitService.isAuthorized {
|
||||||
if newValue && !healthKitService.isAuthorized {
|
isRequestingAuthorization = true
|
||||||
isRequestingAuthorization = true
|
Task {
|
||||||
Task {
|
do {
|
||||||
do {
|
try await healthKitService.requestAuthorization()
|
||||||
try await healthKitService.requestAuthorization()
|
await MainActor.run {
|
||||||
await MainActor.run {
|
healthKitService.setEnabled(true)
|
||||||
healthKitService.setEnabled(true)
|
isRequestingAuthorization = false
|
||||||
isRequestingAuthorization = false
|
}
|
||||||
}
|
} catch {
|
||||||
} catch {
|
await MainActor.run {
|
||||||
await MainActor.run {
|
showingAuthorizationError = true
|
||||||
showingAuthorizationError = true
|
isRequestingAuthorization = false
|
||||||
isRequestingAuthorization = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if newValue {
|
|
||||||
healthKitService.setEnabled(true)
|
|
||||||
} else {
|
|
||||||
healthKitService.setEnabled(false)
|
|
||||||
}
|
}
|
||||||
|
} else if newValue {
|
||||||
|
healthKitService.setEnabled(true)
|
||||||
|
} else {
|
||||||
|
healthKitService.setEnabled(false)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
) {
|
)) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "heart.fill")
|
Image(systemName: "heart.fill")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
@@ -1084,14 +1252,15 @@ struct HealthKitSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isRequestingAuthorization)
|
.disabled(isRequestingAuthorization)
|
||||||
|
|
||||||
if healthKitService.isEnabled {
|
if healthKitService.isEnabled {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack {
|
||||||
Text(
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
"Climbing sessions will be recorded as workouts in Apple Health"
|
Text("Climbing sessions will be recorded as workouts in Apple Health")
|
||||||
)
|
.font(.caption)
|
||||||
.font(.caption)
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1114,52 +1283,7 @@ struct HealthKitSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MusicSection: View {
|
|
||||||
@EnvironmentObject var musicService: MusicService
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section {
|
|
||||||
Toggle(isOn: Binding(
|
|
||||||
get: { musicService.isMusicEnabled },
|
|
||||||
set: { musicService.toggleMusicEnabled($0) }
|
|
||||||
)) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "music.note")
|
|
||||||
.foregroundColor(.pink)
|
|
||||||
Text("Apple Music Integration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if musicService.isMusicEnabled {
|
|
||||||
if !musicService.isAuthorized {
|
|
||||||
Button("Connect Apple Music") {
|
|
||||||
Task {
|
|
||||||
await musicService.checkAuthorizationStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled)
|
|
||||||
Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled)
|
|
||||||
|
|
||||||
Picker("Playlist", selection: $musicService.selectedPlaylistId) {
|
|
||||||
Text("None").tag(nil as String?)
|
|
||||||
ForEach(musicService.playlists, id: \.id) { playlist in
|
|
||||||
Text(playlist.name).tag(playlist.id.rawValue as String?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if musicService.isAutoPlayEnabled {
|
|
||||||
Text("Music will only auto-play if headphones are connected when you start a session.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Music")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
|
|||||||