Compare commits

...

16 Commits

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

2
.gitattributes vendored
View File

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

View File

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

View File

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

View File

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

644
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,6 @@ class ClimbingDataManager: ObservableObject {
let syncService = SyncService()
let healthKitService = HealthKitService.shared
let musicService = MusicService.shared
@Published var isSyncing = false
private enum Keys {
@@ -427,8 +426,7 @@ class ClimbingDataManager: ObservableObject {
gymName: gym.name)
}
musicService.resetSessionPlaybackState()
musicService.playSelectedPlaylistIfHeadphonesConnected()
if healthKitService.isEnabled {
do {
@@ -470,10 +468,7 @@ class ClimbingDataManager: ObservableObject {
await LiveActivityManager.shared.endLiveActivity()
if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") {
musicService.stopPlaybackIfEnabled()
}
musicService.stopPlaybackIfEnabled()
if healthKitService.isEnabled {
do {

View File

@@ -373,35 +373,19 @@ struct AddAttemptView: View {
Section("Additional Details") {
TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextField("Notes (Optional)", text: $notes, axis: .vertical)
.lineLimit(3...6)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
HStack {
Text("Duration (seconds)")
Spacer()
LabeledContent("Duration (seconds)") {
TextField("0", value: $duration, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
.multilineTextAlignment(.trailing)
}
HStack {
Text("Rest Time (seconds)")
Spacer()
LabeledContent("Rest Time (seconds)") {
TextField("0", value: $restTime, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
.multilineTextAlignment(.trailing)
}
}
}
@@ -761,65 +745,14 @@ struct EditAttemptView: View {
@EnvironmentObject var themeManager: ThemeManager
@Environment(\.dismiss) private var dismiss
@State private var selectedProblem: Problem?
@State private var selectedResult: AttemptResult
@State private var highestHold: String
@State private var notes: String
@State private var duration: Int
@State private var restTime: Int
@State private var showingCreateProblem = false
// New problem creation state
@State private var newProblemName = ""
@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
private var problem: Problem? {
dataManager.problem(withId: attempt.problemId)
}
init(attempt: Attempt) {
@@ -834,12 +767,7 @@ struct EditAttemptView: View {
var body: some View {
NavigationStack {
Form {
if !showingCreateProblem {
ProblemSelectionSection()
} else {
CreateProblemSection()
}
ProblemSection()
AttemptDetailsSection()
}
.navigationTitle("Edit Attempt")
@@ -855,269 +783,35 @@ struct EditAttemptView: View {
Button("Update") {
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
private func ProblemSelectionSection() -> some View {
Section("Select Problem") {
if availableProblems.isEmpty {
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
private func ProblemSection() -> some View {
Section("Problem") {
if let problem = problem {
HStack {
Text(climbType.displayName)
Spacer()
if selectedClimbType == climbType {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(themeManager.accentColor)
} else {
Image(systemName: "circle")
.foregroundColor(.gray)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedClimbType = climbType
}
}
}
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")
VStack(alignment: .leading, spacing: 4) {
Text(problem.name ?? "Unnamed Problem")
.font(.headline)
.foregroundColor(themeManager.accentColor)
Text("\(imageData.count) of 5 photos added")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
.font(.caption)
}
.padding(.vertical, 4)
}
.disabled(imageData.count >= 5)
if !imageData.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(imageData.indices, id: \.self) { index in
if let uiImage = UIImage(data: imageData[index]) {
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)
}
}
HStack(spacing: 8) {
Text(problem.climbType.displayName)
.font(.subheadline)
.foregroundColor(.secondary)
Text("")
.foregroundColor(.secondary)
Text(problem.difficulty.grade)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 1)
Spacer()
}
} else {
Text("Problem not found")
.foregroundColor(.secondary)
}
}
}
@@ -1147,158 +841,33 @@ struct EditAttemptView: View {
Section("Additional Details") {
TextField("Highest Hold (Optional)", text: $highestHold)
VStack(alignment: .leading, spacing: 8) {
Text("Notes (Optional)")
.font(.headline)
TextField("Notes (Optional)", text: $notes, axis: .vertical)
.lineLimit(3...6)
TextEditor(text: $notes)
.frame(minHeight: 80)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
)
}
HStack {
Text("Duration (seconds)")
Spacer()
LabeledContent("Duration (seconds)") {
TextField("0", value: $duration, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
.multilineTextAlignment(.trailing)
}
HStack {
Text("Rest Time (seconds)")
Spacer()
LabeledContent("Rest Time (seconds)") {
TextField("0", value: $restTime, format: .number)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
.multilineTextAlignment(.trailing)
}
}
}
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() {
if showingCreateProblem {
guard let gym = gym else { return }
let difficulty = DifficultyGrade(
system: selectedDifficultySystem, grade: newProblemGrade)
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 = []
let updatedAttempt = attempt.updated(
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)
dismiss()
}
}

View File

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

View File

@@ -56,34 +56,38 @@ struct AddEditSessionView: View {
.foregroundColor(.secondary)
} else {
ForEach(dataManager.gyms, id: \.id) { gym in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(gym.name)
.font(.headline)
if let location = gym.location, !location.isEmpty {
Text(location)
.font(.caption)
.foregroundColor(.secondary)
}
gymRow(gym: gym, isSelected: selectedGym?.id == gym.id)
.onTapGesture {
selectedGym = gym
}
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
private func SessionDetailsSection() -> some View {
Section("Session Details") {

View File

@@ -57,8 +57,7 @@ struct CalendarView: View {
if let activeSession = dataManager.activeSession,
let gym = dataManager.gym(withId: activeSession.gymId)
{
ActiveSessionBanner(session: activeSession, gym: gym)
.environmentObject(MusicService.shared)
ActiveSessionBanner(session: activeSession, gym: gym, onNavigateToSession: onNavigateToSession)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)

View File

@@ -1,12 +1,10 @@
import Combine
import MusicKit
import SwiftUI
struct SessionDetailView: View {
let sessionId: UUID
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var themeManager: ThemeManager
@EnvironmentObject var musicService: MusicService
@Environment(\.dismiss) private var dismiss
@State private var showingDeleteAlert = false
@State private var showingAddAttempt = false
@@ -47,12 +45,7 @@ struct SessionDetailView: View {
.listRowBackground(Color.clear)
.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)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
@@ -449,52 +442,7 @@ struct SessionStats {
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 {
NavigationView {

View File

@@ -27,7 +27,9 @@ struct SessionsView: View {
EmptySessionsView()
} else {
if viewMode == .list {
SessionsList()
SessionsList(onNavigateToSession: { sessionId in
selectedSessionId = sessionId
})
} else {
CalendarView(
sessions: completedSessions,
@@ -108,6 +110,7 @@ struct SessionsView: View {
struct SessionsList: View {
@EnvironmentObject var dataManager: ClimbingDataManager
@State private var sessionToDelete: ClimbSession?
var onNavigateToSession: (UUID) -> Void
private var completedSessions: [ClimbSession] {
dataManager.sessions
@@ -121,8 +124,11 @@ struct SessionsList: View {
let gym = dataManager.gym(withId: activeSession.gymId)
{
Section {
ActiveSessionBanner(session: activeSession, gym: gym)
.environmentObject(MusicService.shared)
ActiveSessionBanner(
session: activeSession,
gym: gym,
onNavigateToSession: onNavigateToSession
)
.padding(.horizontal, 16)
.listRowInsets(EdgeInsets(top: 16, leading: 0, bottom: 24, trailing: 0))
.listRowBackground(Color.clear)
@@ -184,8 +190,7 @@ struct ActiveSessionBanner: View {
let session: ClimbSession
let gym: Gym
@EnvironmentObject var dataManager: ClimbingDataManager
@EnvironmentObject var musicService: MusicService
@State private var navigateToDetail = false
var onNavigateToSession: (UUID) -> Void
var body: some View {
HStack {
@@ -210,23 +215,12 @@ struct ActiveSessionBanner: View {
.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)
.contentShape(Rectangle())
.onTapGesture {
navigateToDetail = true
onNavigateToSession(session.id)
}
Button(action: {
@@ -249,9 +243,7 @@ struct ActiveSessionBanner: View {
.fill(.green.opacity(0.1))
.stroke(.green.opacity(0.3), lineWidth: 1)
)
.navigationDestination(isPresented: $navigateToDetail) {
SessionDetailView(sessionId: session.id)
}
}
}

View 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())
}
}

View File

@@ -1,5 +1,4 @@
import HealthKit
import MusicKit
import SwiftUI
import UIKit
import UniformTypeIdentifiers
@@ -24,8 +23,7 @@ struct SettingsView: View {
HealthKitSection()
.environmentObject(dataManager.healthKitService)
MusicSection()
.environmentObject(dataManager.musicService)
AppearanceSection()
@@ -180,13 +178,12 @@ struct AppIconSection: View {
setIcon(nil)
}) {
HStack {
Image(systemName: "triangle.fill")
Text("Peaks")
.foregroundColor(.primary)
Spacer()
if currentIcon == nil {
Image(systemName: "checkmark")
.foregroundColor(.blue)
.foregroundColor(.primary)
}
}
}
@@ -195,13 +192,124 @@ struct AppIconSection: View {
setIcon("Balls")
}) {
HStack {
Image(systemName: "circle.fill")
Text("Balls")
.foregroundColor(.primary)
Spacer()
if currentIcon == "Balls" {
Image(systemName: "checkmark")
.foregroundColor(.blue)
.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)
}
}
}
@@ -574,8 +682,8 @@ struct ExportDataView: View {
private func cleanupTempFile() {
if let fileURL = tempFileURL {
let logTag = Self.logTag // Capture before entering async closure
// Clean up after a delay to ensure sharing is complete
let logTag = Self.logTag
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
try? FileManager.default.removeItem(at: fileURL)
AppLogger.debug(
@@ -644,80 +752,84 @@ struct SyncSection: View {
}
.foregroundColor(.primary)
if syncService.isConfigured {
// Sync Now - only show if connected
// Sync Now - show with proper opacity when not available
Button(action: {
if syncService.isConnected {
Button(action: {
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)
performSync()
}
// Auto-sync configuration - always visible for testing
}) {
HStack {
VStack(alignment: .leading) {
Text("Auto-sync")
Text("Sync automatically on app launch and data changes")
if syncService.isSyncing {
ProgressView()
.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)
.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)
.font(.caption)
.foregroundColor(.red)
.padding(.leading, 24)
Spacer()
}
.padding(.leading, 24)
}
}
.alert("Disconnect from Server", isPresented: $showingDisconnectAlert) {
@@ -773,12 +885,12 @@ struct SyncSettingsView: View {
if selectedProvider == .server {
Section {
TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080"))
TextField("Server URL", text: $serverURL)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token"))
TextField("Auth Token", text: $authToken)
.autocapitalization(.none)
.disableAutocorrection(true)
} header: {
@@ -1107,34 +1219,32 @@ struct HealthKitSection: View {
.foregroundColor(.secondary)
}
} else {
Toggle(
isOn: Binding(
get: { healthKitService.isEnabled },
set: { newValue in
if newValue && !healthKitService.isAuthorized {
isRequestingAuthorization = true
Task {
do {
try await healthKitService.requestAuthorization()
await MainActor.run {
healthKitService.setEnabled(true)
isRequestingAuthorization = false
}
} catch {
await MainActor.run {
showingAuthorizationError = true
isRequestingAuthorization = false
}
Toggle(isOn: Binding(
get: { healthKitService.isEnabled },
set: { newValue in
if newValue && !healthKitService.isAuthorized {
isRequestingAuthorization = true
Task {
do {
try await healthKitService.requestAuthorization()
await MainActor.run {
healthKitService.setEnabled(true)
isRequestingAuthorization = false
}
} catch {
await MainActor.run {
showingAuthorizationError = true
isRequestingAuthorization = false
}
}
} else if newValue {
healthKitService.setEnabled(true)
} else {
healthKitService.setEnabled(false)
}
} else if newValue {
healthKitService.setEnabled(true)
} else {
healthKitService.setEnabled(false)
}
)
) {
}
)) {
HStack {
Image(systemName: "heart.fill")
.foregroundColor(.red)
@@ -1142,14 +1252,15 @@ struct HealthKitSection: View {
}
}
.disabled(isRequestingAuthorization)
if healthKitService.isEnabled {
VStack(alignment: .leading, spacing: 4) {
Text(
"Climbing sessions will be recorded as workouts in Apple Health"
)
.font(.caption)
.foregroundColor(.secondary)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Climbing sessions will be recorded as workouts in Apple Health")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
}
@@ -1172,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 {
SettingsView()