diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 29047c6..276ffd1 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ /* Begin PBXFileReference section */ 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 = ""; }; 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; }; D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -107,6 +108,7 @@ D24C195F2E75002A0045894C = { isa = PBXGroup; children = ( + D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */, D24C196A2E75002A0045894C /* OpenClimb */, D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, D2FE947F2E78E958008CDB25 /* Frameworks */, @@ -389,8 +391,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -410,9 +414,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -429,8 +434,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -450,9 +457,10 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -469,8 +477,9 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -481,7 +490,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -498,8 +507,9 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -510,7 +520,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 5d4fb10..eacec34 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/Info.plist b/ios/OpenClimb/Info.plist index c8ebc60..e65576e 100644 --- a/ios/OpenClimb/Info.plist +++ b/ios/OpenClimb/Info.plist @@ -6,5 +6,7 @@ NSSupportsLiveActivities + NSPhotoLibraryUsageDescription + This app needs access to your photo library to add photos to climbing problems. diff --git a/ios/OpenClimb/OpenClimb.entitlements b/ios/OpenClimb/OpenClimb.entitlements new file mode 100644 index 0000000..2630f0c --- /dev/null +++ b/ios/OpenClimb/OpenClimb.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.atridad.OpenClimb + + + diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 13f8a04..8370854 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -32,6 +32,27 @@ class ClimbingDataManager: ObservableObject { 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() { _ = ImageManager.shared loadAllData() @@ -97,8 +118,13 @@ class ClimbingDataManager: ObservableObject { private func saveGyms() { if let data = try? encoder.encode(gyms) { userDefaults.set(data, forKey: Keys.gyms) - // Share with widget - sharedUserDefaults?.set(data, forKey: Keys.gyms) + // Share with widget - convert to widget format + 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() { if let data = try? encoder.encode(sessions) { userDefaults.set(data, forKey: Keys.sessions) - // Share with widget - sharedUserDefaults?.set(data, forKey: Keys.sessions) + // Share with widget - convert to widget format + 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() { if let data = try? encoder.encode(attempts) { userDefaults.set(data, forKey: Keys.attempts) - // Share with widget - sharedUserDefaults?.set(data, forKey: Keys.attempts) + // Share with widget - convert to widget format + 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 updateWidgetTimeline() } @@ -1020,8 +1067,14 @@ extension ClimbingDataManager { private func updateLiveActivityForActiveSession() { guard let activeSession = activeSession, activeSession.status == .active, - let _ = gym(withId: activeSession.gymId) + let gym = gym(withId: activeSession.gymId) 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 } @@ -1040,6 +1093,16 @@ extension ClimbingDataManager { 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 { await LiveActivityManager.shared.updateLiveActivity( elapsed: elapsedInterval, @@ -1061,6 +1124,14 @@ extension ClimbingDataManager { #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 { if importData.gyms.isEmpty { throw NSError( diff --git a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift index 0d2851e..336b58f 100644 --- a/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddAttemptView.swift @@ -1,3 +1,4 @@ +import PhotosUI import SwiftUI struct AddAttemptView: View { @@ -19,6 +20,8 @@ struct AddAttemptView: View { @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 activeProblems: [Problem] { dataManager.activeProblems(forGym: gym.id) @@ -126,6 +129,8 @@ struct AddAttemptView: View { Button("Back") { showingCreateProblem = false + selectedPhotos = [] + imageData = [] } .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 @@ -310,11 +383,20 @@ struct AddAttemptView: View { 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 + difficulty: difficulty, + imagePaths: imagePaths ) dataManager.addProblem(newProblem) @@ -347,8 +429,26 @@ struct AddAttemptView: View { dataManager.addAttempt(attempt) } + // Clear photo states after saving + selectedPhotos = [] + imageData = [] + 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 { @@ -599,6 +699,8 @@ struct EditAttemptView: View { @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] { guard let session = dataManager.session(withId: attempt.sessionId) else { @@ -727,6 +829,8 @@ struct EditAttemptView: View { Button("Back") { showingCreateProblem = false + selectedPhotos = [] + imageData = [] } .foregroundColor(.blue) } @@ -810,6 +914,74 @@ struct EditAttemptView: 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 @@ -915,11 +1087,20 @@ struct EditAttemptView: View { 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 + difficulty: difficulty, + imagePaths: imagePaths ) dataManager.addProblem(newProblem) @@ -949,8 +1130,26 @@ struct EditAttemptView: View { dataManager.updateAttempt(updatedAttempt) } + // Clear photo states after saving + selectedPhotos = [] + imageData = [] + 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 { @@ -997,6 +1196,7 @@ struct ProblemSelectionImageView: View { ProgressView() .scaleEffect(0.8) } + } } .onAppear { diff --git a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift index 3460c3d..811f64e 100644 --- a/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift +++ b/ios/OpenClimb/Views/AddEdit/AddEditProblemView.swift @@ -1,4 +1,3 @@ - import PhotosUI import SwiftUI @@ -61,11 +60,11 @@ struct AddEditProblemView: View { Form { GymSelectionSection() BasicInfoSection() + PhotosSection() ClimbTypeSection() DifficultySection() LocationAndSetterSection() TagsSection() - PhotosSection() AdditionalInfoSection() } .navigationTitle(isEditing ? "Edit Problem" : "Add Problem") @@ -304,18 +303,30 @@ struct AddEditProblemView: View { @ViewBuilder private func PhotosSection() -> some View { - Section("Photos") { + Section("Photos (Optional)") { PhotosPicker( selection: $selectedPhotos, maxSelectionCount: 5, matching: .images ) { HStack { - Image(systemName: "photo.on.rectangle.angled") + Image(systemName: "camera.fill") .foregroundColor(.blue) - Text("Add Photos (\(imageData.count)/5)") + .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) } if !imageData.isEmpty { diff --git a/ios/SessionStatusLiveExtension.entitlements b/ios/SessionStatusLiveExtension.entitlements new file mode 100644 index 0000000..2630f0c --- /dev/null +++ b/ios/SessionStatusLiveExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.atridad.OpenClimb + + +