From 363fbd676a2bef1fe396faa74e03e161a190eb14 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Mon, 15 Sep 2025 21:01:02 -0600 Subject: [PATCH] Proper 1.0 release for iOS. Pending App Store submission. --- .../ClimbingActivityWidget.swift | 143 +++++++ ios/ClimbingActivityWidget/Info.plist | 31 ++ .../LiveActivityManager.swift | 50 +++ ios/OpenClimb.xcodeproj/project.pbxproj | 192 +++++++++ .../contents.xcworkspacedata | 6 + .../UserInterfaceState.xcuserstate | Bin 47577 -> 62733 bytes .../xcschemes/xcschememanagement.plist | 5 + ios/OpenClimb/ContentView.swift | 7 +- ios/OpenClimb/Info.plist | 2 + ios/OpenClimb/Models/ActivityAttributes.swift | 19 + .../ViewModels/ClimbingDataManager.swift | 137 +++++++ .../ViewModels/LiveActivityManager.swift | 146 +++++++ .../Views/LiveActivityDebugView.swift | 280 +++++++++++++ ios/OpenClimb/Views/SettingsView.swift | 41 +- ios/SessionStatusLive/AppIntent.swift | 18 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + ios/SessionStatusLive/Info.plist | 11 + ios/SessionStatusLive/SessionStatusLive.swift | 381 ++++++++++++++++++ .../SessionStatusLiveBundle.swift | 18 + .../SessionStatusLiveControl.swift | 77 ++++ .../SessionStatusLiveLiveActivity.swift | 223 ++++++++++ 24 files changed, 1848 insertions(+), 2 deletions(-) create mode 100644 ios/ClimbingActivityWidget/ClimbingActivityWidget.swift create mode 100644 ios/ClimbingActivityWidget/Info.plist create mode 100644 ios/OpenClimb.xcodeproj/LiveActivityManager.swift create mode 100644 ios/OpenClimb/Models/ActivityAttributes.swift create mode 100644 ios/OpenClimb/ViewModels/LiveActivityManager.swift create mode 100644 ios/OpenClimb/Views/LiveActivityDebugView.swift create mode 100644 ios/SessionStatusLive/AppIntent.swift create mode 100644 ios/SessionStatusLive/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/SessionStatusLive/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/SessionStatusLive/Assets.xcassets/Contents.json create mode 100644 ios/SessionStatusLive/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 ios/SessionStatusLive/Info.plist create mode 100644 ios/SessionStatusLive/SessionStatusLive.swift create mode 100644 ios/SessionStatusLive/SessionStatusLiveBundle.swift create mode 100644 ios/SessionStatusLive/SessionStatusLiveControl.swift create mode 100644 ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift diff --git a/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift b/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift new file mode 100644 index 0000000..29d8b12 --- /dev/null +++ b/ios/ClimbingActivityWidget/ClimbingActivityWidget.swift @@ -0,0 +1,143 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct ClimbingActivityWidgetBundle: WidgetBundle { + var body: some Widget { + ClimbingActivityWidget() + } +} + +struct ClimbingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SessionActivityAttributes.self) { context in + // Lock Screen/Banner UI goes here + LiveActivityView(context: context) + .activityBackgroundTint(Color.cyan) + .activitySystemActionForegroundColor(Color.black) + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here + DynamicIslandExpandedRegion(.leading) { + Text("πŸ§—β€β™‚οΈ") + .font(.title2) + } + DynamicIslandExpandedRegion(.trailing) { + Text(context.attributes.gymName) + .lineLimit(1) + .font(.caption) + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Label("\(context.state.totalAttempts)", systemImage: "flame") + Spacer() + Label("\(context.state.completedProblems)", systemImage: "checkmark.circle") + Spacer() + TimerView(start: context.attributes.startTime) + } + .font(.caption) + } + } compactLeading: { + Text("πŸ§—β€β™‚οΈ") + } compactTrailing: { + Text("\(context.state.totalAttempts)") + .monospacedDigit() + } minimal: { + Text("πŸ§—β€β™‚οΈ") + } + } + } +} + +struct LiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("πŸ§—β€β™‚οΈ \(context.attributes.gymName)") + .font(.headline) + .lineLimit(1) + Spacer() + TimerView(start: context.attributes.startTime) + .font(.subheadline) + .foregroundColor(.secondary) + } + + HStack(spacing: 20) { + VStack(alignment: .leading, spacing: 2) { + Text("Attempts") + .font(.caption) + .foregroundColor(.secondary) + HStack { + Image(systemName: "flame.fill") + .foregroundColor(.orange) + Text("\(context.state.totalAttempts)") + .font(.title3) + .fontWeight(.semibold) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text("Completed") + .font(.caption) + .foregroundColor(.secondary) + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("\(context.state.completedProblems)") + .font(.title3) + .fontWeight(.semibold) + } + } + + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } +} + +struct TimerView: View { + let start: Date + @State private var now = Date() + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + Text(formatElapsed(from: start, to: now)) + .monospacedDigit() + .onReceive(timer) { time in + now = time + } + } + + private func formatElapsed(from: Date, to: Date) -> String { + let interval = Int(to.timeIntervalSince(from)) + let hours = interval / 3600 + let minutes = (interval % 3600) / 60 + let seconds = interval % 60 + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} + +// Preview for development +#Preview("Live Activity", as: .content, using: SessionActivityAttributes.preview) { + ClimbingActivityWidget() +} contentStates: { + SessionActivityAttributes.ContentState(elapsed: 1234, totalAttempts: 13, completedProblems: 4) + SessionActivityAttributes.ContentState(elapsed: 2400, totalAttempts: 25, completedProblems: 8) +} + +extension SessionActivityAttributes { + static var preview: SessionActivityAttributes { + SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) + } +} diff --git a/ios/ClimbingActivityWidget/Info.plist b/ios/ClimbingActivityWidget/Info.plist new file mode 100644 index 0000000..ae9f1ab --- /dev/null +++ b/ios/ClimbingActivityWidget/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ClimbingActivityWidget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/ios/OpenClimb.xcodeproj/LiveActivityManager.swift b/ios/OpenClimb.xcodeproj/LiveActivityManager.swift new file mode 100644 index 0000000..8e66542 --- /dev/null +++ b/ios/OpenClimb.xcodeproj/LiveActivityManager.swift @@ -0,0 +1,50 @@ +import ActivityKit +import Foundation + +@MainActor +final class LiveActivityManager { + static let shared = LiveActivityManager() + private init() {} + + private var currentActivity: Activity? + + /// Call this when a ClimbSession starts to begin a Live Activity + func startLiveActivity(for session: ClimbSession, gymName: String) async { + await endLiveActivity() + let attributes = SessionActivityAttributes( + gymName: gymName, startTime: session.startTime ?? session.date) + let initialContentState = SessionActivityAttributes.ContentState( + elapsed: 0, + totalAttempts: 0, + completedProblems: 0 + ) + do { + currentActivity = try Activity.request( + attributes: attributes, + contentState: initialContentState, + pushType: nil + ) + } catch { + print("Failed to start live activity: \(error)") + } + } + + /// Call this to update the Live Activity with new session progress + func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async + { + guard let currentActivity else { return } + let updatedContentState = SessionActivityAttributes.ContentState( + elapsed: elapsed, + totalAttempts: totalAttempts, + completedProblems: completedProblems + ) + await currentActivity.update(using: updatedContentState, alertConfiguration: nil) + } + + /// Call this when a ClimbSession ends to end the Live Activity + func endLiveActivity() async { + guard let currentActivity else { return } + await currentActivity.end(using: nil, dismissalPolicy: .immediate) + self.currentActivity = nil + } +} diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 2543c0a..80e8460 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -6,8 +6,44 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + D2FE94822E78E95C008CDB25 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE94802E78E958008CDB25 /* ActivityKit.framework */; }; + D2FE948D2E78FEE0008CDB25 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */; }; + D2FE948F2E78FEE0008CDB25 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */; }; + D2FE94A02E78FEE1008CDB25 /* SessionStatusLiveExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D2FE94A82E78FFB7008CDB25 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2FE94802E78E958008CDB25 /* ActivityKit.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D24C19602E75002A0045894C /* Project object */; + proxyType = 1; + remoteGlobalIDString = D2FE948A2E78FEE0008CDB25; + remoteInfo = SessionStatusLiveExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + D2FE94A52E78FEE1008CDB25 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D2FE94A02E78FEE1008CDB25 /* SessionStatusLiveExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 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; }; + D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -18,6 +54,13 @@ ); target = D24C19672E75002A0045894C /* OpenClimb */; }; + D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -29,6 +72,14 @@ path = OpenClimb; sourceTree = ""; }; + D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */, + ); + path = SessionStatusLive; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -36,6 +87,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D2FE94822E78E95C008CDB25 /* ActivityKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D2FE94882E78FEE0008CDB25 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D2FE94A82E78FFB7008CDB25 /* ActivityKit.framework in Frameworks */, + D2FE948F2E78FEE0008CDB25 /* SwiftUI.framework in Frameworks */, + D2FE948D2E78FEE0008CDB25 /* WidgetKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -46,6 +108,8 @@ isa = PBXGroup; children = ( D24C196A2E75002A0045894C /* OpenClimb */, + D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, + D2FE947F2E78E958008CDB25 /* Frameworks */, D24C19692E75002A0045894C /* Products */, ); sourceTree = ""; @@ -54,10 +118,21 @@ isa = PBXGroup; children = ( D24C19682E75002A0045894C /* OpenClimb.app */, + D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */, ); name = Products; sourceTree = ""; }; + D2FE947F2E78E958008CDB25 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D2FE94802E78E958008CDB25 /* ActivityKit.framework */, + D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */, + D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -68,10 +143,12 @@ D24C19642E75002A0045894C /* Sources */, D24C19652E75002A0045894C /* Frameworks */, D24C19662E75002A0045894C /* Resources */, + D2FE94A52E78FEE1008CDB25 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( D24C196A2E75002A0045894C /* OpenClimb */, @@ -83,6 +160,28 @@ productReference = D24C19682E75002A0045894C /* OpenClimb.app */; productType = "com.apple.product-type.application"; }; + D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */; + buildPhases = ( + D2FE94872E78FEE0008CDB25 /* Sources */, + D2FE94882E78FEE0008CDB25 /* Frameworks */, + D2FE94892E78FEE0008CDB25 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D2FE94902E78FEE0008CDB25 /* SessionStatusLive */, + ); + name = SessionStatusLiveExtension; + packageProductDependencies = ( + ); + productName = SessionStatusLiveExtension; + productReference = D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -96,6 +195,9 @@ D24C19672E75002A0045894C = { CreatedOnToolsVersion = 26.0; }; + D2FE948A2E78FEE0008CDB25 = { + CreatedOnToolsVersion = 26.0; + }; }; }; buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */; @@ -113,6 +215,7 @@ projectRoot = ""; targets = ( D24C19672E75002A0045894C /* OpenClimb */, + D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */, ); }; /* End PBXProject section */ @@ -125,6 +228,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D2FE94892E78FEE0008CDB25 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -135,8 +245,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D2FE94872E78FEE0008CDB25 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */; + targetProxy = D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ D24C19712E75002A0045894C /* Debug */ = { isa = XCBuildConfiguration; @@ -335,6 +460,64 @@ }; name = Release; }; + D2FE94A22E78FEE1008CDB25 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4BC9Y2LL4B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SessionStatusLive/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D2FE94A32E78FEE1008CDB25 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 4BC9Y2LL4B; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SessionStatusLive/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = SessionStatusLive; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -356,6 +539,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D2FE94A22E78FEE1008CDB25 /* Debug */, + D2FE94A32E78FEE1008CDB25 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = D24C19602E75002A0045894C /* Project object */; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 919434a..7ffda4e 100644 --- a/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/ios/OpenClimb.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,10 @@ + + + + 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 dac7ec28142cb329ceb48e874fe90f0ae0eb0429..4811c96609557cf402cbff4ae2ffcb82d72c6ea3 100644 GIT binary patch literal 62733 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 diff --git a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist index 4327198..53d0216 100644 --- a/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ios/OpenClimb.xcodeproj/xcuserdata/atridad.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + SessionStatusLiveExtension.xcscheme_^#shared#^_ + + orderHint + 0 + diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index 03535a2..15d44e6 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -1,9 +1,9 @@ - import SwiftUI struct ContentView: View { @StateObject private var dataManager = ClimbingDataManager() @State private var selectedTab = 0 + @Environment(\.scenePhase) private var scenePhase var body: some View { TabView(selection: $selectedTab) { @@ -43,6 +43,11 @@ struct ContentView: View { .tag(4) } .environmentObject(dataManager) + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + dataManager.onAppBecomeActive() + } + } .overlay(alignment: .top) { if let message = dataManager.successMessage { SuccessMessageView(message: message) diff --git a/ios/OpenClimb/Info.plist b/ios/OpenClimb/Info.plist index ff579a6..c8ebc60 100644 --- a/ios/OpenClimb/Info.plist +++ b/ios/OpenClimb/Info.plist @@ -4,5 +4,7 @@ UIFileSharingEnabled + NSSupportsLiveActivities + diff --git a/ios/OpenClimb/Models/ActivityAttributes.swift b/ios/OpenClimb/Models/ActivityAttributes.swift new file mode 100644 index 0000000..afabbaa --- /dev/null +++ b/ios/OpenClimb/Models/ActivityAttributes.swift @@ -0,0 +1,19 @@ +import ActivityKit +import Foundation + +struct SessionActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var elapsed: TimeInterval + var totalAttempts: Int + var completedProblems: Int + } + + var gymName: String + var startTime: Date +} + +extension SessionActivityAttributes { + static var preview: SessionActivityAttributes { + SessionActivityAttributes(gymName: "Summit Climbing", startTime: Date()) + } +} diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index ef395c5..57b26bf 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -3,6 +3,10 @@ import Foundation import SwiftUI import UniformTypeIdentifiers +#if canImport(WidgetKit) + import WidgetKit +#endif + @MainActor class ClimbingDataManager: ObservableObject { @@ -16,6 +20,7 @@ class ClimbingDataManager: ObservableObject { @Published var successMessage: String? private let userDefaults = UserDefaults.standard + private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -35,6 +40,9 @@ class ClimbingDataManager: ObservableObject { Task { try? await Task.sleep(nanoseconds: 2_000_000_000) await performImageMaintenance() + + // Check if we need to restart Live Activity for active session + await checkAndRestartLiveActivity() } } @@ -89,24 +97,34 @@ 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) } } private func saveProblems() { if let data = try? encoder.encode(problems) { userDefaults.set(data, forKey: Keys.problems) + // Share with widget + sharedUserDefaults?.set(data, forKey: Keys.problems) } } 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) } } 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) + // Update widget timeline + updateWidgetTimeline() } } @@ -216,6 +234,14 @@ class ClimbingDataManager: ObservableObject { successMessage = "Session started successfully" clearMessageAfterDelay() + + // MARK: - Start Live Activity for new session + if let gym = gym(withId: gymId) { + Task { + await LiveActivityManager.shared.startLiveActivity( + for: newSession, gymName: gym.name) + } + } } func endSession(_ sessionId: UUID) { @@ -234,6 +260,11 @@ class ClimbingDataManager: ObservableObject { saveSessions() successMessage = "Session completed successfully" clearMessageAfterDelay() + + // MARK: - End Live Activity after session ends + Task { + await LiveActivityManager.shared.endLiveActivity() + } } } @@ -249,6 +280,9 @@ class ClimbingDataManager: ObservableObject { saveSessions() successMessage = "Session updated successfully" clearMessageAfterDelay() + + // Update Live Activity when session updates + updateLiveActivityForActiveSession() } } @@ -290,6 +324,9 @@ class ClimbingDataManager: ObservableObject { successMessage = "Attempt logged successfully" clearMessageAfterDelay() + + // Update Live Activity when new attempt is added + updateLiveActivityForActiveSession() } func updateAttempt(_ attempt: Attempt) { @@ -298,6 +335,9 @@ class ClimbingDataManager: ObservableObject { saveAttempts() successMessage = "Attempt updated successfully" clearMessageAfterDelay() + + // Update Live Activity when attempt is updated + updateLiveActivityForActiveSession() } } @@ -306,6 +346,9 @@ class ClimbingDataManager: ObservableObject { saveAttempts() successMessage = "Attempt deleted successfully" clearMessageAfterDelay() + + // Update Live Activity when attempt is deleted + updateLiveActivityForActiveSession() } func attempts(forSession sessionId: UUID) -> [Attempt] { @@ -924,6 +967,100 @@ extension ClimbingDataManager { """ } + func testLiveActivity() { + print("πŸ§ͺ Testing Live Activity functionality...") + + // Check Live Activity availability + let status = LiveActivityManager.shared.checkLiveActivityAvailability() + print(status) + + // Test with dummy data if we have a gym + guard let testGym = gyms.first else { + print("❌ No gyms available for testing") + return + } + + // Create a test session + let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity") + + Task { + await LiveActivityManager.shared.startLiveActivity( + for: testSession, gymName: testGym.name) + + // Wait a bit then update + try? await Task.sleep(nanoseconds: 2_000_000_000) + await LiveActivityManager.shared.updateLiveActivity( + elapsed: 120, totalAttempts: 5, completedProblems: 1) + + // Wait then end + try? await Task.sleep(nanoseconds: 5_000_000_000) + await LiveActivityManager.shared.endLiveActivity() + } + } + + private func checkAndRestartLiveActivity() async { + guard let activeSession = activeSession else { return } + + if let gym = gym(withId: activeSession.gymId) { + await LiveActivityManager.shared.restartLiveActivityIfNeeded( + activeSession: activeSession, + gymName: gym.name + ) + } + } + + /// Call this when app becomes active to check for Live Activity restart + func onAppBecomeActive() { + Task { + await checkAndRestartLiveActivity() + } + } + + /// Update Live Activity with current session data + private func updateLiveActivityForActiveSession() { + guard let activeSession = activeSession, + activeSession.status == .active, + let gym = gym(withId: activeSession.gymId) + else { + return + } + + let attemptsForSession = attempts(forSession: activeSession.id) + let totalAttempts = attemptsForSession.count + + let completedProblemIds = Set( + attemptsForSession.filter { $0.result.isSuccessful }.map { $0.problemId } + ) + let completedProblems = completedProblemIds.count + + let elapsedInterval: TimeInterval + if let startTime = activeSession.startTime { + elapsedInterval = Date().timeIntervalSince(startTime) + } else { + elapsedInterval = 0 + } + + Task { + await LiveActivityManager.shared.updateLiveActivity( + elapsed: elapsedInterval, + totalAttempts: totalAttempts, + completedProblems: completedProblems + ) + } + } + + /// Manually force Live Activity update (useful for debugging) + func forceLiveActivityUpdate() { + updateLiveActivityForActiveSession() + } + + /// Update widget timeline when data changes + private func updateWidgetTimeline() { + #if canImport(WidgetKit) + WidgetCenter.shared.reloadTimelines(ofKind: "SessionStatusLive") + #endif + } + private func validateImportData(_ importData: ClimbDataExport) throws { if importData.gyms.isEmpty { throw NSError( diff --git a/ios/OpenClimb/ViewModels/LiveActivityManager.swift b/ios/OpenClimb/ViewModels/LiveActivityManager.swift new file mode 100644 index 0000000..97c0a3f --- /dev/null +++ b/ios/OpenClimb/ViewModels/LiveActivityManager.swift @@ -0,0 +1,146 @@ +import ActivityKit +import Foundation + +@MainActor +final class LiveActivityManager { + static let shared = LiveActivityManager() + private init() {} + + private var currentActivity: Activity? + + /// Check if there's an active session and restart Live Activity if needed + func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { + // If we have an active session but no Live Activity, restart it + guard let activeSession = activeSession, + let gymName = gymName, + activeSession.status == .active + else { + return + } + + // Check if we already have a running Live Activity + if currentActivity != nil { + print("ℹ️ Live Activity already running") + return + } + + print("πŸ”„ Restarting Live Activity for existing session") + await startLiveActivity(for: activeSession, gymName: gymName) + } + + /// Call this when a ClimbSession starts to begin a Live Activity + func startLiveActivity(for session: ClimbSession, gymName: String) async { + print("πŸ”΄ Starting Live Activity for gym: \(gymName)") + + await endLiveActivity() + + let attributes = SessionActivityAttributes( + gymName: gymName, startTime: session.startTime ?? session.date) + let initialContentState = SessionActivityAttributes.ContentState( + elapsed: 0, + totalAttempts: 0, + completedProblems: 0 + ) + + do { + let activity = try Activity.request( + attributes: attributes, + contentState: initialContentState, + pushType: nil + ) + self.currentActivity = activity + print("βœ… Live Activity started successfully: \(activity.id)") + } catch { + print("❌ Failed to start live activity: \(error)") + print("Error details: \(error.localizedDescription)") + + // Check specific error types + if error.localizedDescription.contains("authorization") { + print("Authorization error - check Live Activity permissions in Settings") + } else if error.localizedDescription.contains("content") { + print("Content error - check ActivityAttributes structure") + } + } + } + + /// Call this to update the Live Activity with new session progress + func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async + { + guard let currentActivity else { + print("⚠️ No current activity to update") + return + } + + print( + "πŸ”„ Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)" + ) + + let updatedContentState = SessionActivityAttributes.ContentState( + elapsed: elapsed, + totalAttempts: totalAttempts, + completedProblems: completedProblems + ) + + do { + await currentActivity.update(using: updatedContentState, alertConfiguration: nil) + print("βœ… Live Activity updated successfully") + } catch { + print("❌ Failed to update live activity: \(error)") + } + } + + /// Call this when a ClimbSession ends to end the Live Activity + func endLiveActivity() async { + guard let currentActivity else { + print("ℹ️ No current activity to end") + return + } + + print("πŸ”΄ Ending Live Activity: \(currentActivity.id)") + + do { + await currentActivity.end(using: nil, dismissalPolicy: .immediate) + self.currentActivity = nil + print("βœ… Live Activity ended successfully") + } catch { + print("❌ Failed to end live activity: \(error)") + self.currentActivity = nil + } + } + + /// Check if Live Activities are available and authorized + func checkLiveActivityAvailability() -> String { + let authorizationInfo = ActivityAuthorizationInfo() + let status = authorizationInfo.areActivitiesEnabled + + let message = """ + Live Activity Status: + β€’ Enabled: \(status) + β€’ Authorization: \(authorizationInfo.areActivitiesEnabled ? "Granted" : "Denied/Unknown") + β€’ Current Activity: \(currentActivity?.id.description ?? "None") + """ + + print(message) + return message + } + + /// Start periodic updates for Live Activity + func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int) + { + guard currentActivity != nil else { return } + + Task { + while currentActivity != nil { + let elapsed = Date().timeIntervalSince(session.startTime ?? session.date) + await updateLiveActivity( + elapsed: elapsed, + totalAttempts: totalAttempts, + completedProblems: completedProblems + ) + + // Wait 30 seconds before next update + try? await Task.sleep(nanoseconds: 30_000_000_000) + } + } + } +} diff --git a/ios/OpenClimb/Views/LiveActivityDebugView.swift b/ios/OpenClimb/Views/LiveActivityDebugView.swift new file mode 100644 index 0000000..88843b6 --- /dev/null +++ b/ios/OpenClimb/Views/LiveActivityDebugView.swift @@ -0,0 +1,280 @@ +// +// LiveActivityDebugView.swift +// OpenClimb +// +// Created by Assistant on 2025-09-15. +// + +import SwiftUI + +struct LiveActivityDebugView: View { + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var debugOutput: String = "" + @State private var isTestRunning = false + + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 20) { + + // Header + VStack(alignment: .leading, spacing: 8) { + Text("Live Activity Debug") + .font(.title) + .fontWeight(.bold) + + Text("Test and debug Live Activities for climbing sessions") + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Status Section + GroupBox("Current Status") { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "circle.fill") + .foregroundColor(dataManager.activeSession != nil ? .green : .red) + Text( + "Active Session: \(dataManager.activeSession != nil ? "Yes" : "No")" + ) + } + + HStack { + Image(systemName: "building.2") + Text("Total Gyms: \(dataManager.gyms.count)") + } + + if let activeSession = dataManager.activeSession, + let gym = dataManager.gym(withId: activeSession.gymId) + { + HStack { + Image(systemName: "location") + Text("Current Gym: \(gym.name)") + } + } + } + } + + // Test Buttons + GroupBox("Live Activity Tests") { + VStack(spacing: 16) { + + Button(action: checkStatus) { + HStack { + Image(systemName: "checkmark.circle") + Text("Check Live Activity Status") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(isTestRunning) + + Button(action: testLiveActivity) { + HStack { + Image(systemName: isTestRunning ? "hourglass" : "play.circle") + Text( + isTestRunning + ? "Running Test..." : "Run Full Live Activity Test") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isTestRunning || dataManager.gyms.isEmpty) + + Button(action: forceLiveActivityUpdate) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Force Live Activity Update") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(dataManager.activeSession == nil) + + if dataManager.gyms.isEmpty { + Text("⚠️ Add at least one gym to test Live Activities") + .font(.caption) + .foregroundColor(.orange) + } + + if dataManager.activeSession != nil { + Button(action: endCurrentSession) { + HStack { + Image(systemName: "stop.circle") + Text("End Current Session") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(isTestRunning) + } + } + } + + // Debug Output + GroupBox("Debug Output") { + ScrollView { + ScrollViewReader { proxy in + VStack(alignment: .leading, spacing: 4) { + if debugOutput.isEmpty { + Text("No debug output yet. Run a test to see details.") + .foregroundColor(.secondary) + .italic() + } else { + Text(debugOutput) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .id("bottom") + .onChange(of: debugOutput) { _ in + withAnimation { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + } + .frame(maxHeight: 200) + .background(Color(UIColor.systemGray6)) + .cornerRadius(8) + } + + // Clear button + HStack { + Spacer() + Button("Clear Output") { + debugOutput = "" + } + .buttonStyle(.bordered) + } + + Spacer() + } + .padding() + } + .navigationTitle("Live Activity Debug") + .navigationBarTitleDisplayMode(.inline) + } + + private func appendDebugOutput(_ message: String) { + let timestamp = DateFormatter.timeFormatter.string(from: Date()) + let newLine = "[\(timestamp)] \(message)" + + DispatchQueue.main.async { + if debugOutput.isEmpty { + debugOutput = newLine + } else { + debugOutput += "\n" + newLine + } + } + } + + private func checkStatus() { + appendDebugOutput("πŸ” Checking Live Activity status...") + + let status = LiveActivityManager.shared.checkLiveActivityAvailability() + appendDebugOutput("Status: \(status)") + + // Check iOS version + if #available(iOS 16.1, *) { + appendDebugOutput("βœ… iOS version supports Live Activities") + } else { + appendDebugOutput("❌ iOS version does not support Live Activities (requires 16.1+)") + } + + // Check if we're on simulator + #if targetEnvironment(simulator) + appendDebugOutput("⚠️ Running on Simulator - Live Activities have limited functionality") + #else + appendDebugOutput("βœ… Running on device - Live Activities should work fully") + #endif + } + + private func testLiveActivity() { + guard !dataManager.gyms.isEmpty else { + appendDebugOutput("❌ No gyms available for testing") + return + } + + isTestRunning = true + appendDebugOutput("πŸ§ͺ Starting Live Activity test...") + + Task { + defer { + DispatchQueue.main.async { + isTestRunning = false + } + } + + // Test with first gym + let testGym = dataManager.gyms[0] + appendDebugOutput("Using gym: \(testGym.name)") + + // Create test session + let testSession = ClimbSession( + gymId: testGym.id, notes: "Test session for Live Activity") + appendDebugOutput("Created test session") + + // Start Live Activity + await LiveActivityManager.shared.startLiveActivity( + for: testSession, gymName: testGym.name) + appendDebugOutput("Live Activity start request sent") + + // Wait and update + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + appendDebugOutput("Updating Live Activity with test data...") + await LiveActivityManager.shared.updateLiveActivity( + elapsed: 180, + totalAttempts: 8, + completedProblems: 2 + ) + + // Another update + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + appendDebugOutput("Second update...") + await LiveActivityManager.shared.updateLiveActivity( + elapsed: 360, + totalAttempts: 15, + completedProblems: 4 + ) + + // End after delay + try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + appendDebugOutput("Ending Live Activity...") + await LiveActivityManager.shared.endLiveActivity() + + appendDebugOutput("🏁 Live Activity test completed!") + } + } + + private func endCurrentSession() { + guard let activeSession = dataManager.activeSession else { + appendDebugOutput("❌ No active session to end") + return + } + + appendDebugOutput("πŸ›‘ Ending current session: \(activeSession.id)") + dataManager.endSession(activeSession.id) + appendDebugOutput("βœ… Session ended") + } + + private func forceLiveActivityUpdate() { + appendDebugOutput("πŸ”„ Forcing Live Activity update...") + dataManager.forceLiveActivityUpdate() + appendDebugOutput("βœ… Live Activity update sent") + } +} + +extension DateFormatter { + static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + }() +} + +#Preview { + LiveActivityDebugView() + .environmentObject(ClimbingDataManager.preview) +} diff --git a/ios/OpenClimb/Views/SettingsView.swift b/ios/OpenClimb/Views/SettingsView.swift index 38fbbe9..5f882b8 100644 --- a/ios/OpenClimb/Views/SettingsView.swift +++ b/ios/OpenClimb/Views/SettingsView.swift @@ -1,4 +1,3 @@ - import SwiftUI import UniformTypeIdentifiers @@ -17,6 +16,8 @@ struct SettingsView: View { activeSheet: $activeSheet ) + LiveActivitySection() + ImageStorageSection() AppInfoSection() @@ -508,6 +509,44 @@ struct ImportDataView: View { } } +struct LiveActivitySection: View { + @EnvironmentObject var dataManager: ClimbingDataManager + @State private var showingDebugView = false + + var body: some View { + Section("Live Activities") { + NavigationLink(destination: LiveActivityDebugView()) { + HStack { + Image(systemName: "bell.badge") + .foregroundColor(.purple) + Text("Debug Live Activities") + Spacer() + } + } + .foregroundColor(.primary) + + Button(action: { + dataManager.testLiveActivity() + }) { + HStack { + Image(systemName: "play.circle") + .foregroundColor(.blue) + Text("Quick Test") + Spacer() + } + } + .foregroundColor(.primary) + .disabled(dataManager.gyms.isEmpty) + + if dataManager.gyms.isEmpty { + Text("Add a gym first to test Live Activities") + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} + #Preview { SettingsView() .environmentObject(ClimbingDataManager.preview) diff --git a/ios/SessionStatusLive/AppIntent.swift b/ios/SessionStatusLive/AppIntent.swift new file mode 100644 index 0000000..1efec63 --- /dev/null +++ b/ios/SessionStatusLive/AppIntent.swift @@ -0,0 +1,18 @@ +// +// AppIntent.swift +// SessionStatusLive +// +// Created by Atridad Lahiji on 2025-09-15. +// + +import WidgetKit +import AppIntents + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Configuration" } + static var description: IntentDescription { "This is an example widget." } + + // An example configurable parameter. + @Parameter(title: "Favorite Emoji", default: "πŸ˜ƒ") + var favoriteEmoji: String +} diff --git a/ios/SessionStatusLive/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/SessionStatusLive/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/SessionStatusLive/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SessionStatusLive/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/SessionStatusLive/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ios/SessionStatusLive/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SessionStatusLive/Assets.xcassets/Contents.json b/ios/SessionStatusLive/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/SessionStatusLive/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SessionStatusLive/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/SessionStatusLive/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ios/SessionStatusLive/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/SessionStatusLive/Info.plist b/ios/SessionStatusLive/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/ios/SessionStatusLive/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/SessionStatusLive/SessionStatusLive.swift b/ios/SessionStatusLive/SessionStatusLive.swift new file mode 100644 index 0000000..afa3c22 --- /dev/null +++ b/ios/SessionStatusLive/SessionStatusLive.swift @@ -0,0 +1,381 @@ +// +// SessionStatusLive.swift +// SessionStatusLive +// +// Created by Atridad Lahiji on 2025-09-15. +// + +import SwiftUI +import WidgetKit + +struct ClimbingStatsProvider: TimelineProvider { + typealias Entry = ClimbingStatsEntry + + func placeholder(in context: Context) -> ClimbingStatsEntry { + ClimbingStatsEntry( + date: Date(), + weeklyAttempts: 42, + todayAttempts: 8, + currentStreak: 3, + favoriteGym: "Summit Climbing" + ) + } + + func getSnapshot(in context: Context, completion: @escaping (ClimbingStatsEntry) -> Void) { + let entry = ClimbingStatsEntry( + date: Date(), + weeklyAttempts: 42, + todayAttempts: 8, + currentStreak: 3, + favoriteGym: "Summit Climbing" + ) + completion(entry) + } + + func getTimeline( + in context: Context, completion: @escaping (Timeline) -> Void + ) { + let currentDate = Date() + let stats = loadClimbingStats() + + let entry = ClimbingStatsEntry( + date: currentDate, + weeklyAttempts: stats.weeklyAttempts, + todayAttempts: stats.todayAttempts, + currentStreak: stats.currentStreak, + favoriteGym: stats.favoriteGym + ) + + // Update every hour + let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + completion(timeline) + } + + private func loadClimbingStats() -> ClimbingStats { + let userDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") + + // Load attempts from UserDefaults + guard let attemptsData = userDefaults?.data(forKey: "openclimb_attempts"), + let attempts = try? JSONDecoder().decode([WidgetAttempt].self, from: attemptsData) + else { + return ClimbingStats( + weeklyAttempts: 0, todayAttempts: 0, currentStreak: 0, favoriteGym: "No Data") + } + + // Load sessions for streak calculation + let sessionsData = (userDefaults?.data(forKey: "openclimb_sessions"))! + let sessions = (try? JSONDecoder().decode([WidgetSession].self, from: sessionsData)) ?? [] + + // Load gyms for favorite gym name + let gymsData = (userDefaults?.data(forKey: "openclimb_gyms"))! + let gyms = (try? JSONDecoder().decode([WidgetGym].self, from: gymsData)) ?? [] + + let calendar = Calendar.current + let now = Date() + let weekAgo = calendar.date(byAdding: .day, value: -7, to: now)! + let startOfToday = calendar.startOfDay(for: now) + + // Calculate weekly attempts + let weeklyAttempts = attempts.filter { attempt in + attempt.timestamp >= weekAgo + }.count + + // Calculate today's attempts + let todayAttempts = attempts.filter { attempt in + attempt.timestamp >= startOfToday + }.count + + // Calculate current streak (consecutive days with sessions) + let currentStreak = calculateStreak(sessions: sessions) + + // Find favorite gym + let favoriteGym = findFavoriteGym(sessions: sessions, gyms: gyms) + + return ClimbingStats( + weeklyAttempts: weeklyAttempts, + todayAttempts: todayAttempts, + currentStreak: currentStreak, + favoriteGym: favoriteGym + ) + } + + private func calculateStreak(sessions: [WidgetSession]) -> Int { + let calendar = Calendar.current + let completedSessions = sessions.filter { $0.status == "COMPLETED" } + .sorted { $0.date > $1.date } + + guard !completedSessions.isEmpty else { return 0 } + + var streak = 0 + var currentDate = calendar.startOfDay(for: Date()) + + for session in completedSessions { + let sessionDate = calendar.startOfDay(for: session.date) + + if sessionDate == currentDate { + streak += 1 + currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate)! + } else if sessionDate == calendar.date(byAdding: .day, value: -1, to: currentDate) { + streak += 1 + currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate)! + } else { + break + } + } + + return streak + } + + private func findFavoriteGym(sessions: [WidgetSession], gyms: [WidgetGym]) -> String { + let gymCounts = Dictionary(grouping: sessions, by: { $0.gymId }) + .mapValues { $0.count } + + guard let mostUsedGymId = gymCounts.max(by: { $0.value < $1.value })?.key, + let gym = gyms.first(where: { $0.id == mostUsedGymId }) + else { + return "No Data" + } + + return gym.name + } +} + +struct ClimbingStatsEntry: TimelineEntry { + let date: Date + let weeklyAttempts: Int + let todayAttempts: Int + let currentStreak: Int + let favoriteGym: String +} + +struct ClimbingStats { + let weeklyAttempts: Int + let todayAttempts: Int + let currentStreak: Int + let favoriteGym: String +} + +struct SessionStatusLiveEntryView: View { + var entry: ClimbingStatsEntry + @Environment(\.widgetFamily) var family + + var body: some View { + switch family { + case .systemSmall: + SmallWidgetView(entry: entry) + case .systemMedium: + MediumWidgetView(entry: entry) + default: + SmallWidgetView(entry: entry) + } + } +} + +struct SmallWidgetView: View { + let entry: ClimbingStatsEntry + + var body: some View { + VStack(spacing: 8) { + // Header + HStack { + if let uiImage = UIImage(named: "AppIcon") { + Image(uiImage: uiImage) + .resizable() + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Image(systemName: "figure.climbing") + .font(.title2) + .foregroundColor(.accentColor) + } + Spacer() + Text("This Week") + .font(.caption) + .foregroundColor(.secondary) + } + + // Main stat - weekly attempts + VStack(spacing: 2) { + Text("\(entry.weeklyAttempts)") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(.primary) + Text("Attempts") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + // Bottom stats + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("\(entry.todayAttempts)") + .font(.headline) + .fontWeight(.semibold) + Text("Today") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 2) { + Text("\(entry.currentStreak)") + .font(.headline) + .fontWeight(.semibold) + Image(systemName: "flame.fill") + .foregroundColor(.orange) + .font(.caption) + } + Text("Day Streak") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + .padding() + } +} + +struct MediumWidgetView: View { + let entry: ClimbingStatsEntry + + var body: some View { + VStack(spacing: 12) { + // Header + HStack { + HStack(spacing: 6) { + if let uiImage = UIImage(named: "AppIcon") { + Image(uiImage: uiImage) + .resizable() + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Image(systemName: "figure.climbing") + .font(.title2) + .foregroundColor(.accentColor) + } + Text("Climbing Stats") + .font(.headline) + .fontWeight(.semibold) + } + Spacer() + Text("This Week") + .font(.caption) + .foregroundColor(.secondary) + } + + // Main stats row + HStack(spacing: 20) { + VStack(spacing: 4) { + Text("\(entry.weeklyAttempts)") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + Text("Total Attempts") + .font(.caption) + .foregroundColor(.secondary) + } + + VStack(spacing: 4) { + Text("\(entry.todayAttempts)") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.blue) + Text("Today") + .font(.caption) + .foregroundColor(.secondary) + } + + VStack(spacing: 4) { + HStack(spacing: 4) { + Text("\(entry.currentStreak)") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.orange) + Image(systemName: "flame.fill") + .foregroundColor(.orange) + .font(.title3) + } + Text("Day Streak") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Bottom info + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Favorite Gym") + .font(.caption2) + .foregroundColor(.secondary) + Text(entry.favoriteGym) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + } + Spacer() + } + } + .padding() + } +} + +struct SessionStatusLive: Widget { + let kind: String = "SessionStatusLive" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ClimbingStatsProvider()) { entry in + SessionStatusLiveEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Climbing Stats") + .description("Track your climbing attempts and streaks") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +// Simplified data models for widget use +struct WidgetAttempt: Codable { + let id: String + let sessionId: String + let problemId: String + let timestamp: Date + let result: String +} + +struct WidgetSession: Codable { + let id: String + let gymId: String + let date: Date + let status: String +} + +struct WidgetGym: Codable { + let id: String + let name: String +} + +#Preview(as: .systemSmall) { + SessionStatusLive() +} timeline: { + ClimbingStatsEntry( + date: .now, + weeklyAttempts: 42, + todayAttempts: 8, + currentStreak: 3, + favoriteGym: "Summit Climbing" + ) + ClimbingStatsEntry( + date: .now, + weeklyAttempts: 58, + todayAttempts: 12, + currentStreak: 5, + favoriteGym: "Boulder Zone" + ) +} diff --git a/ios/SessionStatusLive/SessionStatusLiveBundle.swift b/ios/SessionStatusLive/SessionStatusLiveBundle.swift new file mode 100644 index 0000000..f632dd5 --- /dev/null +++ b/ios/SessionStatusLive/SessionStatusLiveBundle.swift @@ -0,0 +1,18 @@ +// +// SessionStatusLiveBundle.swift +// SessionStatusLive +// +// Created by Atridad Lahiji on 2025-09-15. +// + +import WidgetKit +import SwiftUI + +@main +struct SessionStatusLiveBundle: WidgetBundle { + var body: some Widget { + SessionStatusLive() + SessionStatusLiveControl() + SessionStatusLiveLiveActivity() + } +} diff --git a/ios/SessionStatusLive/SessionStatusLiveControl.swift b/ios/SessionStatusLive/SessionStatusLiveControl.swift new file mode 100644 index 0000000..cd7f525 --- /dev/null +++ b/ios/SessionStatusLive/SessionStatusLiveControl.swift @@ -0,0 +1,77 @@ +// +// SessionStatusLiveControl.swift +// SessionStatusLive +// +// Created by Atridad Lahiji on 2025-09-15. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct SessionStatusLiveControl: ControlWidget { + static let kind: String = "com.atridad.OpenClimb.SessionStatusLive" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension SessionStatusLiveControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift b/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift new file mode 100644 index 0000000..56c68b3 --- /dev/null +++ b/ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift @@ -0,0 +1,223 @@ +// +// SessionStatusLiveLiveActivity.swift +// SessionStatusLive +// +// Created by Atridad Lahiji on 2025-09-15. +// + +import ActivityKit +import SwiftUI +import WidgetKit + +struct SessionActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var elapsed: TimeInterval + var totalAttempts: Int + var completedProblems: Int + } + + var gymName: String + var startTime: Date +} + +struct SessionStatusLiveLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SessionActivityAttributes.self) { context in + LiveActivityView(context: context) + .activityBackgroundTint(Color.blue.opacity(0.2)) + .activitySystemActionForegroundColor(Color.primary) + + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + VStack(alignment: .leading, spacing: 4) { + if let uiImage = UIImage(named: "AppIcon") { + Image(uiImage: uiImage) + .resizable() + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Image(systemName: "figure.climbing") + .font(.title3) + .foregroundColor(.accentColor) + } + Text(context.attributes.gymName) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + DynamicIslandExpandedRegion(.trailing) { + VStack(alignment: .trailing, spacing: 4) { + LiveTimerView(start: context.attributes.startTime) + .font(.title2) + .fontWeight(.bold) + .monospacedDigit() + HStack(spacing: 4) { + Image(systemName: "flame.fill") + .foregroundColor(.orange) + .font(.caption) + Text("\(context.state.totalAttempts)") + .font(.caption) + .fontWeight(.semibold) + Text("attempts") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + DynamicIslandExpandedRegion(.bottom) { + HStack { + Text("\(context.state.completedProblems) completed") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + Text("Tap to open") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } compactLeading: { + Image(systemName: "figure.climbing") + .font(.footnote) + .foregroundColor(.accentColor) + } compactTrailing: { + LiveTimerView(start: context.attributes.startTime, compact: true) + } minimal: { + Image(systemName: "figure.climbing") + .font(.system(size: 8)) + .foregroundColor(.accentColor) + } + } + } +} + +struct LiveActivityView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 16) { + LiveTimerView(start: context.attributes.startTime) + .font(.largeTitle) + .fontWeight(.bold) + .monospacedDigit() + .frame(minWidth: 80) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + if let uiImage = UIImage(named: "AppIcon") { + Image(uiImage: uiImage) + .resizable() + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } else { + Image(systemName: "figure.climbing") + .font(.title2) + .foregroundColor(.accentColor) + } + VStack(alignment: .leading, spacing: 0) { + Text(context.attributes.gymName) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(1) + Text("Climbing Session") + .font(.caption) + .foregroundColor(.secondary) + } + } + + HStack(spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "flame.fill") + .foregroundColor(.orange) + .font(.title3) + Text("\(context.state.totalAttempts)") + .font(.title2) + .fontWeight(.bold) + } + Text("Total Attempts") + .font(.caption) + .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title3) + Text("\(context.state.completedProblems)") + .font(.title2) + .fontWeight(.bold) + } + Text("Completed") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } +} + +struct LiveTimerView: View { + let start: Date + let compact: Bool + let minimal: Bool + + init(start: Date, compact: Bool = false, minimal: Bool = false) { + self.start = start + self.compact = compact + self.minimal = minimal + } + + var body: some View { + if minimal { + Text(timerInterval: start...Date.distantFuture, countsDown: false) + .font(.system(size: 8, weight: .medium, design: .monospaced)) + } else if compact { + Text(timerInterval: start...Date.distantFuture, countsDown: false) + .font(.caption.monospacedDigit()) + .frame(maxWidth: 40) + .minimumScaleFactor(0.7) + } else { + Text(timerInterval: start...Date.distantFuture, countsDown: false) + .monospacedDigit() + } + } +} + +// Alias for compatibility +typealias TimerView = LiveTimerView + +extension SessionActivityAttributes { + fileprivate static var preview: SessionActivityAttributes { + SessionActivityAttributes( + gymName: "Summit Climbing Gym", startTime: Date().addingTimeInterval(-1234)) + } +} + +extension SessionActivityAttributes.ContentState { + fileprivate static var active: SessionActivityAttributes.ContentState { + SessionActivityAttributes.ContentState( + elapsed: 1234, totalAttempts: 8, completedProblems: 2) + } + + fileprivate static var busy: SessionActivityAttributes.ContentState { + SessionActivityAttributes.ContentState( + elapsed: 3600, totalAttempts: 25, completedProblems: 7) + } +} + +#Preview("Notification", as: .content, using: SessionActivityAttributes.preview) { + SessionStatusLiveLiveActivity() +} contentStates: { + SessionActivityAttributes.ContentState.active + SessionActivityAttributes.ContentState.busy +}