Compare commits

..

6 Commits

Author SHA1 Message Date
f68963afbc oops 2025-09-23 22:20:11 -06:00
f1bc61d202 1.0.2 - Widget and Photos fixes 2025-09-23 21:57:45 -06:00
57b16c89ad 1.0.1 (6) 2025-09-20 15:08:59 -06:00
44b9b7bb9e Release README 2025-09-20 14:33:16 -06:00
7839d52001 ??? 2025-09-20 14:32:12 -06:00
fff8123978 1.0.1 (5) 2025-09-20 14:32:04 -06:00
11 changed files with 816 additions and 284 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View 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>

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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(

View 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>