Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f68963afbc
|
|||
|
f1bc61d202
|
|||
|
57b16c89ad
|
|||
|
44b9b7bb9e
|
|||
|
7839d52001
|
|||
|
fff8123978
|
@@ -5,7 +5,7 @@ This is a FOSS app meant to help climbers track their sessions, routes/problems,
|
|||||||
## Versions
|
## Versions
|
||||||
|
|
||||||
- Android:1.4.2
|
- Android:1.4.2
|
||||||
- iOS: 1.0.0
|
- iOS: 1.0.1
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ For Android do one of the following:
|
|||||||
|
|
||||||
For iOS:
|
For iOS:
|
||||||
|
|
||||||
**Stay tuned for an upcoming Testflight or App Store release!**
|
Download from the AppStore [here](https://apps.apple.com/ca/app/openclimb/id6752592783)!
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; };
|
||||||
D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||||
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
D24C195F2E75002A0045894C = {
|
D24C195F2E75002A0045894C = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
|
||||||
D24C196A2E75002A0045894C /* OpenClimb */,
|
D24C196A2E75002A0045894C /* OpenClimb */,
|
||||||
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
|
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
|
||||||
D2FE947F2E78E958008CDB25 /* Frameworks */,
|
D2FE947F2E78E958008CDB25 /* Frameworks */,
|
||||||
@@ -389,8 +391,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -410,9 +414,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -429,8 +434,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -450,9 +457,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -469,8 +477,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -481,7 +490,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -498,8 +507,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -510,7 +520,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
Binary file not shown.
@@ -6,5 +6,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>This app needs access to your photo library to add photos to climbing problems.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
10
ios/OpenClimb/OpenClimb.entitlements
Normal file
10
ios/OpenClimb/OpenClimb.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.atridad.OpenClimb</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -32,6 +32,27 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
static let activeSession = "openclimb_active_session"
|
static let activeSession = "openclimb_active_session"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget data models
|
||||||
|
private struct WidgetAttempt: Codable {
|
||||||
|
let id: String
|
||||||
|
let sessionId: String
|
||||||
|
let problemId: String
|
||||||
|
let timestamp: Date
|
||||||
|
let result: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WidgetSession: Codable {
|
||||||
|
let id: String
|
||||||
|
let gymId: String
|
||||||
|
let date: Date
|
||||||
|
let status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WidgetGym: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_ = ImageManager.shared
|
_ = ImageManager.shared
|
||||||
loadAllData()
|
loadAllData()
|
||||||
@@ -97,8 +118,13 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
private func saveGyms() {
|
private func saveGyms() {
|
||||||
if let data = try? encoder.encode(gyms) {
|
if let data = try? encoder.encode(gyms) {
|
||||||
userDefaults.set(data, forKey: Keys.gyms)
|
userDefaults.set(data, forKey: Keys.gyms)
|
||||||
// Share with widget
|
// Share with widget - convert to widget format
|
||||||
sharedUserDefaults?.set(data, forKey: Keys.gyms)
|
let widgetGyms = gyms.map { gym in
|
||||||
|
WidgetGym(id: gym.id.uuidString, name: gym.name)
|
||||||
|
}
|
||||||
|
if let widgetData = try? encoder.encode(widgetGyms) {
|
||||||
|
sharedUserDefaults?.set(widgetData, forKey: Keys.gyms)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,16 +139,37 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
private func saveSessions() {
|
private func saveSessions() {
|
||||||
if let data = try? encoder.encode(sessions) {
|
if let data = try? encoder.encode(sessions) {
|
||||||
userDefaults.set(data, forKey: Keys.sessions)
|
userDefaults.set(data, forKey: Keys.sessions)
|
||||||
// Share with widget
|
// Share with widget - convert to widget format
|
||||||
sharedUserDefaults?.set(data, forKey: Keys.sessions)
|
let widgetSessions = sessions.map { session in
|
||||||
|
WidgetSession(
|
||||||
|
id: session.id.uuidString,
|
||||||
|
gymId: session.gymId.uuidString,
|
||||||
|
date: session.date,
|
||||||
|
status: session.status.rawValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let widgetData = try? encoder.encode(widgetSessions) {
|
||||||
|
sharedUserDefaults?.set(widgetData, forKey: Keys.sessions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAttempts() {
|
private func saveAttempts() {
|
||||||
if let data = try? encoder.encode(attempts) {
|
if let data = try? encoder.encode(attempts) {
|
||||||
userDefaults.set(data, forKey: Keys.attempts)
|
userDefaults.set(data, forKey: Keys.attempts)
|
||||||
// Share with widget
|
// Share with widget - convert to widget format
|
||||||
sharedUserDefaults?.set(data, forKey: Keys.attempts)
|
let widgetAttempts = attempts.map { attempt in
|
||||||
|
WidgetAttempt(
|
||||||
|
id: attempt.id.uuidString,
|
||||||
|
sessionId: attempt.sessionId.uuidString,
|
||||||
|
problemId: attempt.problemId.uuidString,
|
||||||
|
timestamp: attempt.timestamp,
|
||||||
|
result: attempt.result.rawValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let widgetData = try? encoder.encode(widgetAttempts) {
|
||||||
|
sharedUserDefaults?.set(widgetData, forKey: Keys.attempts)
|
||||||
|
}
|
||||||
// Update widget timeline
|
// Update widget timeline
|
||||||
updateWidgetTimeline()
|
updateWidgetTimeline()
|
||||||
}
|
}
|
||||||
@@ -1020,8 +1067,14 @@ extension ClimbingDataManager {
|
|||||||
private func updateLiveActivityForActiveSession() {
|
private func updateLiveActivityForActiveSession() {
|
||||||
guard let activeSession = activeSession,
|
guard let activeSession = activeSession,
|
||||||
activeSession.status == .active,
|
activeSession.status == .active,
|
||||||
let _ = gym(withId: activeSession.gymId)
|
let gym = gym(withId: activeSession.gymId)
|
||||||
else {
|
else {
|
||||||
|
print("⚠️ Live Activity update skipped - no active session or gym")
|
||||||
|
if let session = activeSession {
|
||||||
|
print(" Session ID: \(session.id)")
|
||||||
|
print(" Session Status: \(session.status)")
|
||||||
|
print(" Gym ID: \(session.gymId)")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,6 +1093,16 @@ extension ClimbingDataManager {
|
|||||||
elapsedInterval = 0
|
elapsedInterval = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("🔄 Live Activity Update Debug:")
|
||||||
|
print(" Session ID: \(activeSession.id)")
|
||||||
|
print(" Gym: \(gym.name)")
|
||||||
|
print(" Total attempts in session: \(totalAttempts)")
|
||||||
|
print(" Completed problems: \(completedProblems)")
|
||||||
|
print(" Elapsed time: \(elapsedInterval) seconds")
|
||||||
|
print(
|
||||||
|
" All attempts for session: \(attemptsForSession.map { "\($0.result) - Problem: \($0.problemId)" })"
|
||||||
|
)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await LiveActivityManager.shared.updateLiveActivity(
|
await LiveActivityManager.shared.updateLiveActivity(
|
||||||
elapsed: elapsedInterval,
|
elapsed: elapsedInterval,
|
||||||
@@ -1061,6 +1124,14 @@ extension ClimbingDataManager {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Debug function to manually trigger widget data update
|
||||||
|
func debugUpdateWidgetData() {
|
||||||
|
// Force save all data to widget
|
||||||
|
saveGyms()
|
||||||
|
saveSessions()
|
||||||
|
saveAttempts()
|
||||||
|
}
|
||||||
|
|
||||||
private func validateImportData(_ importData: ClimbDataExport) throws {
|
private func validateImportData(_ importData: ClimbDataExport) throws {
|
||||||
if importData.gyms.isEmpty {
|
if importData.gyms.isEmpty {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AddAttemptView: View {
|
struct AddAttemptView: View {
|
||||||
@@ -19,6 +20,8 @@ struct AddAttemptView: View {
|
|||||||
@State private var newProblemGrade = ""
|
@State private var newProblemGrade = ""
|
||||||
@State private var selectedClimbType: ClimbType = .boulder
|
@State private var selectedClimbType: ClimbType = .boulder
|
||||||
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
@State private var selectedDifficultySystem: DifficultySystem = .vScale
|
||||||
|
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||||
|
@State private var imageData: [Data] = []
|
||||||
|
|
||||||
private var activeProblems: [Problem] {
|
private var activeProblems: [Problem] {
|
||||||
dataManager.activeProblems(forGym: gym.id)
|
dataManager.activeProblems(forGym: gym.id)
|
||||||
@@ -126,6 +129,8 @@ struct AddAttemptView: View {
|
|||||||
|
|
||||||
Button("Back") {
|
Button("Back") {
|
||||||
showingCreateProblem = false
|
showingCreateProblem = false
|
||||||
|
selectedPhotos = []
|
||||||
|
imageData = []
|
||||||
}
|
}
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
@@ -209,6 +214,74 @@ struct AddAttemptView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Photos (Optional)") {
|
||||||
|
PhotosPicker(
|
||||||
|
selection: $selectedPhotos,
|
||||||
|
maxSelectionCount: 5,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.title2)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Add Photos")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("\(imageData.count) of 5 photos added")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedPhotos) { _, _ in
|
||||||
|
Task {
|
||||||
|
await loadSelectedPhotos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -310,11 +383,20 @@ struct AddAttemptView: View {
|
|||||||
let difficulty = DifficultyGrade(
|
let difficulty = DifficultyGrade(
|
||||||
system: selectedDifficultySystem, grade: newProblemGrade)
|
system: selectedDifficultySystem, grade: newProblemGrade)
|
||||||
|
|
||||||
|
// Save images and get paths
|
||||||
|
var imagePaths: [String] = []
|
||||||
|
for data in imageData {
|
||||||
|
if let relativePath = ImageManager.shared.saveImageData(data) {
|
||||||
|
imagePaths.append(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let newProblem = Problem(
|
let newProblem = Problem(
|
||||||
gymId: gym.id,
|
gymId: gym.id,
|
||||||
name: newProblemName.isEmpty ? nil : newProblemName,
|
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||||
climbType: selectedClimbType,
|
climbType: selectedClimbType,
|
||||||
difficulty: difficulty
|
difficulty: difficulty,
|
||||||
|
imagePaths: imagePaths
|
||||||
)
|
)
|
||||||
|
|
||||||
dataManager.addProblem(newProblem)
|
dataManager.addProblem(newProblem)
|
||||||
@@ -347,8 +429,26 @@ struct AddAttemptView: View {
|
|||||||
dataManager.addAttempt(attempt)
|
dataManager.addAttempt(attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear photo states after saving
|
||||||
|
selectedPhotos = []
|
||||||
|
imageData = []
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = newImageData
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProblemSelectionRow: View {
|
struct ProblemSelectionRow: View {
|
||||||
@@ -592,9 +692,43 @@ struct EditAttemptView: View {
|
|||||||
@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
|
||||||
|
@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] = []
|
||||||
|
|
||||||
private var availableProblems: [Problem] {
|
private var availableProblems: [Problem] {
|
||||||
dataManager.problems.filter { $0.isActive }
|
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) {
|
||||||
@@ -609,10 +743,57 @@ struct EditAttemptView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
Form {
|
Form {
|
||||||
|
if !showingCreateProblem {
|
||||||
|
ProblemSelectionSection()
|
||||||
|
} else {
|
||||||
|
CreateProblemSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
AttemptDetailsSection()
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Attempt")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Update") {
|
||||||
|
updateAttempt()
|
||||||
|
}
|
||||||
|
.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
selectedProblem = dataManager.problem(withId: attempt.problemId)
|
||||||
|
setupInitialValues()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedClimbType) {
|
||||||
|
updateDifficultySystem()
|
||||||
|
}
|
||||||
|
.onChange(of: selectedDifficultySystem) {
|
||||||
|
resetGradeIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func ProblemSelectionSection() -> some View {
|
||||||
Section("Select Problem") {
|
Section("Select Problem") {
|
||||||
if availableProblems.isEmpty {
|
if availableProblems.isEmpty {
|
||||||
Text("No problems available")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("No active problems in this gym")
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Button("Create New Problem") {
|
||||||
|
showingCreateProblem = true
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
} else {
|
} else {
|
||||||
LazyVGrid(
|
LazyVGrid(
|
||||||
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
|
||||||
@@ -628,10 +809,184 @@ struct EditAttemptView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Button("Create New Problem") {
|
||||||
|
showingCreateProblem = true
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Result") {
|
@ViewBuilder
|
||||||
|
private func CreateProblemSection() -> some View {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Create New Problem")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Back") {
|
||||||
|
showingCreateProblem = false
|
||||||
|
selectedPhotos = []
|
||||||
|
imageData = []
|
||||||
|
}
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Problem Details") {
|
||||||
|
TextField("Problem Name", text: $newProblemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Climb Type") {
|
||||||
|
ForEach(availableClimbTypes, id: \.self) { climbType in
|
||||||
|
HStack {
|
||||||
|
Text(climbType.displayName)
|
||||||
|
Spacer()
|
||||||
|
if selectedClimbType == climbType {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
} 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(.blue)
|
||||||
|
} 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 ? .blue : .gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Photos (Optional)") {
|
||||||
|
PhotosPicker(
|
||||||
|
selection: $selectedPhotos,
|
||||||
|
maxSelectionCount: 5,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.title2)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Add Photos")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("\(imageData.count) of 5 photos added")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedPhotos) { _, _ in
|
||||||
|
Task {
|
||||||
|
await loadSelectedPhotos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func AttemptDetailsSection() -> some View {
|
||||||
|
Section("Attempt Result") {
|
||||||
ForEach(AttemptResult.allCases, id: \.self) { result in
|
ForEach(AttemptResult.allCases, id: \.self) { result in
|
||||||
HStack {
|
HStack {
|
||||||
Text(result.displayName)
|
Text(result.displayName)
|
||||||
@@ -651,7 +1006,7 @@ struct EditAttemptView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Details") {
|
Section("Additional Details") {
|
||||||
TextField("Highest Hold (Optional)", text: $highestHold)
|
TextField("Highest Hold (Optional)", text: $highestHold)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -686,29 +1041,81 @@ struct EditAttemptView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Edit Attempt")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
private var canSave: Bool {
|
||||||
.toolbar {
|
if showingCreateProblem {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
return !newProblemGrade.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
Button("Cancel") {
|
} else {
|
||||||
dismiss()
|
return selectedProblem != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
private func setupInitialValues() {
|
||||||
Button("Update") {
|
guard let gym = gym else { return }
|
||||||
updateAttempt()
|
|
||||||
|
// Auto-select climb type if there's only one available
|
||||||
|
if gym.supportedClimbTypes.count == 1 {
|
||||||
|
selectedClimbType = gym.supportedClimbTypes.first!
|
||||||
}
|
}
|
||||||
.disabled(selectedProblem == nil)
|
|
||||||
|
updateDifficultySystem()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDifficultySystem() {
|
||||||
|
let available = availableDifficultySystems
|
||||||
|
|
||||||
|
if !available.contains(selectedDifficultySystem) {
|
||||||
|
selectedDifficultySystem = available.first ?? .custom
|
||||||
|
}
|
||||||
|
|
||||||
|
if available.count == 1 {
|
||||||
|
selectedDifficultySystem = available.first!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.onAppear {
|
private func resetGradeIfNeeded() {
|
||||||
selectedProblem = dataManager.problem(withId: attempt.problemId)
|
let availableGrades = selectedDifficultySystem.availableGrades
|
||||||
|
if !availableGrades.isEmpty && !availableGrades.contains(newProblemGrade) {
|
||||||
|
newProblemGrade = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAttempt() {
|
private func updateAttempt() {
|
||||||
|
if showingCreateProblem {
|
||||||
|
guard let gym = gym else { return }
|
||||||
|
|
||||||
|
let difficulty = DifficultyGrade(
|
||||||
|
system: selectedDifficultySystem, grade: newProblemGrade)
|
||||||
|
|
||||||
|
// Save images and get paths
|
||||||
|
var imagePaths: [String] = []
|
||||||
|
for data in imageData {
|
||||||
|
if let relativePath = ImageManager.shared.saveImageData(data) {
|
||||||
|
imagePaths.append(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newProblem = Problem(
|
||||||
|
gymId: gym.id,
|
||||||
|
name: newProblemName.isEmpty ? nil : newProblemName,
|
||||||
|
climbType: selectedClimbType,
|
||||||
|
difficulty: difficulty,
|
||||||
|
imagePaths: imagePaths
|
||||||
|
)
|
||||||
|
|
||||||
|
dataManager.addProblem(newProblem)
|
||||||
|
|
||||||
|
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 }
|
guard selectedProblem != nil else { return }
|
||||||
|
|
||||||
let updatedAttempt = attempt.updated(
|
let updatedAttempt = attempt.updated(
|
||||||
@@ -721,8 +1128,28 @@ struct EditAttemptView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
dataManager.updateAttempt(updatedAttempt)
|
dataManager.updateAttempt(updatedAttempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear photo states after saving
|
||||||
|
selectedPhotos = []
|
||||||
|
imageData = []
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = newImageData
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@@ -769,6 +1196,7 @@ struct ProblemSelectionImageView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -61,11 +60,11 @@ struct AddEditProblemView: View {
|
|||||||
Form {
|
Form {
|
||||||
GymSelectionSection()
|
GymSelectionSection()
|
||||||
BasicInfoSection()
|
BasicInfoSection()
|
||||||
|
PhotosSection()
|
||||||
ClimbTypeSection()
|
ClimbTypeSection()
|
||||||
DifficultySection()
|
DifficultySection()
|
||||||
LocationAndSetterSection()
|
LocationAndSetterSection()
|
||||||
TagsSection()
|
TagsSection()
|
||||||
PhotosSection()
|
|
||||||
AdditionalInfoSection()
|
AdditionalInfoSection()
|
||||||
}
|
}
|
||||||
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
|
.navigationTitle(isEditing ? "Edit Problem" : "Add Problem")
|
||||||
@@ -304,18 +303,30 @@ struct AddEditProblemView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func PhotosSection() -> some View {
|
private func PhotosSection() -> some View {
|
||||||
Section("Photos") {
|
Section("Photos (Optional)") {
|
||||||
PhotosPicker(
|
PhotosPicker(
|
||||||
selection: $selectedPhotos,
|
selection: $selectedPhotos,
|
||||||
maxSelectionCount: 5,
|
maxSelectionCount: 5,
|
||||||
matching: .images
|
matching: .images
|
||||||
) {
|
) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "photo.on.rectangle.angled")
|
Image(systemName: "camera.fill")
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
Text("Add Photos (\(imageData.count)/5)")
|
.font(.title2)
|
||||||
Spacer()
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Add Photos")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
Text("\(imageData.count) of 5 photos added")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !imageData.isEmpty {
|
if !imageData.isEmpty {
|
||||||
|
|||||||
@@ -104,13 +104,14 @@ struct StatCard: View {
|
|||||||
struct ProgressChartSection: View {
|
struct ProgressChartSection: View {
|
||||||
@EnvironmentObject var dataManager: ClimbingDataManager
|
@EnvironmentObject var dataManager: ClimbingDataManager
|
||||||
@State private var selectedSystem: DifficultySystem = .vScale
|
@State private var selectedSystem: DifficultySystem = .vScale
|
||||||
|
@State private var showAllTime: Bool = true
|
||||||
|
|
||||||
private var progressData: [ProgressDataPoint] {
|
private var gradeCountData: [GradeCount] {
|
||||||
calculateProgressOverTime()
|
calculateGradeCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var usedSystems: [DifficultySystem] {
|
private var usedSystems: [DifficultySystem] {
|
||||||
let uniqueSystems = Set(progressData.map { $0.difficultySystem })
|
let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem })
|
||||||
return uniqueSystems.sorted {
|
return uniqueSystems.sorted {
|
||||||
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
|
let order: [DifficultySystem] = [.vScale, .font, .yds, .custom]
|
||||||
let firstIndex = order.firstIndex(of: $0) ?? order.count
|
let firstIndex = order.firstIndex(of: $0) ?? order.count
|
||||||
@@ -121,13 +122,50 @@ struct ProgressChartSection: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack {
|
Text("Grade Distribution")
|
||||||
Text("Progress Over Time")
|
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
// Toggles section
|
||||||
|
HStack {
|
||||||
|
// Time period toggle
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: {
|
||||||
|
showAllTime = true
|
||||||
|
}) {
|
||||||
|
Text("All Time")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(showAllTime ? .blue : .clear)
|
||||||
|
.stroke(.blue.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundColor(showAllTime ? .white : .blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showAllTime = false
|
||||||
|
}) {
|
||||||
|
Text("7 Days")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(!showAllTime ? .blue : .clear)
|
||||||
|
.stroke(.blue.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundColor(!showAllTime ? .white : .blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Scale selector (only show if multiple systems)
|
||||||
if usedSystems.count > 1 {
|
if usedSystems.count > 1 {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(usedSystems, id: \.self) { system in
|
ForEach(usedSystems, id: \.self) { system in
|
||||||
@@ -164,24 +202,22 @@ struct ProgressChartSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let filteredData = progressData.filter { $0.difficultySystem == selectedSystem }
|
let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem }
|
||||||
|
|
||||||
if !filteredData.isEmpty {
|
if !filteredData.isEmpty {
|
||||||
LineChartView(data: filteredData, selectedSystem: selectedSystem)
|
BarChartView(data: filteredData)
|
||||||
.frame(height: 200)
|
.frame(height: 200)
|
||||||
|
|
||||||
Text(
|
Text("Successful climbs by grade")
|
||||||
"Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session"
|
|
||||||
)
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
Image(systemName: "chart.bar")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Text("No progress data available for \(selectedSystem.displayName) system")
|
Text("No data available for \(selectedSystem.displayName) system")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -201,38 +237,125 @@ struct ProgressChartSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateProgressOverTime() -> [ProgressDataPoint] {
|
private func calculateGradeCounts() -> [GradeCount] {
|
||||||
let sessions = dataManager.completedSessions().sorted { $0.date < $1.date }
|
|
||||||
let problems = dataManager.problems
|
let problems = dataManager.problems
|
||||||
let attempts = dataManager.attempts
|
let attempts = dataManager.attempts
|
||||||
|
|
||||||
return sessions.compactMap { session in
|
// Filter attempts by time period
|
||||||
let sessionAttempts = attempts.filter { $0.sessionId == session.id }
|
let filteredAttempts: [Attempt]
|
||||||
let attemptedProblemIds = sessionAttempts.map { $0.problemId }
|
if showAllTime {
|
||||||
|
filteredAttempts = attempts.filter { $0.result.isSuccessful }
|
||||||
|
} else {
|
||||||
|
let sevenDaysAgo =
|
||||||
|
Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||||
|
filteredAttempts = attempts.filter {
|
||||||
|
$0.result.isSuccessful && $0.timestamp >= sevenDaysAgo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attempted problems
|
||||||
|
let attemptedProblemIds = filteredAttempts.map { $0.problemId }
|
||||||
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
|
let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) }
|
||||||
|
|
||||||
// Group problems by difficulty system
|
// Group by difficulty system and grade
|
||||||
let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system }
|
var gradeCounts: [String: GradeCount] = [:]
|
||||||
|
|
||||||
// Create data points for each system used in this session
|
for problem in attemptedProblems {
|
||||||
return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in
|
let successfulAttemptsForProblem = filteredAttempts.filter {
|
||||||
guard
|
$0.problemId == problem.id
|
||||||
let highestGradeProblem = systemProblems.max(by: {
|
|
||||||
$0.difficulty.numericValue < $1.difficulty.numericValue
|
|
||||||
})
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
let count = successfulAttemptsForProblem.count
|
||||||
|
|
||||||
return ProgressDataPoint(
|
let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)"
|
||||||
date: session.date,
|
|
||||||
maxGrade: highestGradeProblem.difficulty.grade,
|
if let existing = gradeCounts[key] {
|
||||||
maxGradeNumeric: highestGradeProblem.difficulty.numericValue,
|
gradeCounts[key] = GradeCount(
|
||||||
climbType: highestGradeProblem.climbType,
|
grade: existing.grade,
|
||||||
difficultySystem: system
|
count: existing.count + count,
|
||||||
|
gradeNumeric: existing.gradeNumeric,
|
||||||
|
difficultySystem: existing.difficultySystem
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
gradeCounts[key] = GradeCount(
|
||||||
|
grade: problem.difficulty.grade,
|
||||||
|
count: count,
|
||||||
|
gradeNumeric: problem.difficulty.numericValue,
|
||||||
|
difficultySystem: problem.difficulty.system
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.flatMap { $0 }
|
}
|
||||||
|
|
||||||
|
return Array(gradeCounts.values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GradeCount {
|
||||||
|
let grade: String
|
||||||
|
let count: Int
|
||||||
|
let gradeNumeric: Int
|
||||||
|
let difficultySystem: DifficultySystem
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BarChartView: View {
|
||||||
|
let data: [GradeCount]
|
||||||
|
|
||||||
|
private var sortedData: [GradeCount] {
|
||||||
|
data.sorted { $0.gradeNumeric < $1.gradeNumeric }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var maxCount: Int {
|
||||||
|
data.map { $0.count }.max() ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let chartWidth = geometry.size.width - 40
|
||||||
|
let chartHeight = geometry.size.height - 40
|
||||||
|
let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8
|
||||||
|
let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2
|
||||||
|
|
||||||
|
if sortedData.isEmpty {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.clear)
|
||||||
|
.overlay(
|
||||||
|
Text("No data")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
// Chart area
|
||||||
|
HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) {
|
||||||
|
ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
// Bar
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(.blue)
|
||||||
|
.frame(
|
||||||
|
width: barWidth,
|
||||||
|
height: CGFloat(gradeCount.count) / CGFloat(maxCount)
|
||||||
|
* chartHeight * 0.8
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Text("\(gradeCount.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.opacity(gradeCount.count > 0 ? 1 : 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grade label
|
||||||
|
Text(gradeCount.grade)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: chartHeight)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,139 +503,6 @@ struct RecentActivitySection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LineChartView: View {
|
|
||||||
let data: [ProgressDataPoint]
|
|
||||||
let selectedSystem: DifficultySystem
|
|
||||||
|
|
||||||
private var uniqueGrades: [String] {
|
|
||||||
if selectedSystem == .custom {
|
|
||||||
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
|
|
||||||
return (Int(grade1) ?? 0) > (Int(grade2) ?? 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in
|
|
||||||
let grade1Data = data.first(where: { $0.maxGrade == grade1 })
|
|
||||||
let grade2Data = data.first(where: { $0.maxGrade == grade2 })
|
|
||||||
return (grade1Data?.maxGradeNumeric ?? 0)
|
|
||||||
> (grade2Data?.maxGradeNumeric ?? 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var minGrade: Int {
|
|
||||||
data.map { $0.maxGradeNumeric }.min() ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private var maxGrade: Int {
|
|
||||||
data.map { $0.maxGradeNumeric }.max() ?? 1
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gradeRange: Int {
|
|
||||||
max(maxGrade - minGrade, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
let chartWidth = geometry.size.width - 40
|
|
||||||
let chartHeight = geometry.size.height - 40
|
|
||||||
|
|
||||||
if data.isEmpty {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.clear)
|
|
||||||
.overlay(
|
|
||||||
Text("No data")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
// Y-axis labels
|
|
||||||
VStack {
|
|
||||||
ForEach(0..<min(5, uniqueGrades.count), id: \.self) { i in
|
|
||||||
let gradeLabel = i < uniqueGrades.count ? uniqueGrades[i] : ""
|
|
||||||
|
|
||||||
Text(gradeLabel)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(width: 30, alignment: .trailing)
|
|
||||||
|
|
||||||
if i < min(4, uniqueGrades.count - 1) {
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: chartHeight)
|
|
||||||
|
|
||||||
// Chart area
|
|
||||||
ZStack {
|
|
||||||
// Grid lines
|
|
||||||
ForEach(0..<5) { i in
|
|
||||||
let y = CGFloat(i) * chartHeight / 4
|
|
||||||
Rectangle()
|
|
||||||
.fill(.gray.opacity(0.2))
|
|
||||||
.frame(height: 0.5)
|
|
||||||
.offset(y: y - chartHeight / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Line chart
|
|
||||||
if data.count > 1 {
|
|
||||||
Path { path in
|
|
||||||
for (index, point) in data.enumerated() {
|
|
||||||
let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1)
|
|
||||||
let normalizedY =
|
|
||||||
CGFloat(point.maxGradeNumeric - minGrade)
|
|
||||||
/ CGFloat(gradeRange)
|
|
||||||
let y = chartHeight - (normalizedY * chartHeight)
|
|
||||||
|
|
||||||
if index == 0 {
|
|
||||||
path.move(to: CGPoint(x: x, y: y))
|
|
||||||
} else {
|
|
||||||
path.addLine(to: CGPoint(x: x, y: y))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.stroke(.blue, lineWidth: 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data points
|
|
||||||
ForEach(data.indices, id: \.self) { index in
|
|
||||||
let point = data[index]
|
|
||||||
let x =
|
|
||||||
data.count == 1
|
|
||||||
? chartWidth / 2
|
|
||||||
: CGFloat(index) * chartWidth / CGFloat(data.count - 1)
|
|
||||||
let normalizedY =
|
|
||||||
CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange)
|
|
||||||
let y = chartHeight - (normalizedY * chartHeight)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(.blue)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
.position(x: x, y: y)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(.white, lineWidth: 2)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
.position(x: x, y: y)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: chartWidth, height: chartHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ProgressDataPoint {
|
|
||||||
let date: Date
|
|
||||||
let maxGrade: String
|
|
||||||
let maxGradeNumeric: Int
|
|
||||||
let climbType: ClimbType
|
|
||||||
let difficultySystem: DifficultySystem
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AnalyticsView()
|
AnalyticsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
@@ -138,13 +138,12 @@ struct ActiveSessionBanner: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
navigateToDetail = true
|
navigateToDetail = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
dataManager.endSession(session.id)
|
dataManager.endSession(session.id)
|
||||||
}) {
|
}) {
|
||||||
@@ -155,6 +154,7 @@ struct ActiveSessionBanner: View {
|
|||||||
.background(Color.red)
|
.background(Color.red)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
10
ios/SessionStatusLiveExtension.entitlements
Normal file
10
ios/SessionStatusLiveExtension.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.atridad.OpenClimb</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Reference in New Issue
Block a user