From afd954785a9a61caf6b65a2f2dd51f44feb5e5d5 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 -> 62911 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..9c0a56cb14f94a4eef974e3d2630f0530feda0aa 100644 GIT binary patch literal 62911 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℞pwB{Mjvjn##xE;amYcGvAAz=$j%!> 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 delta 25294 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 0 + SessionStatusLiveExtension.xcscheme_^#shared#^_ + + orderHint + 1 + 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 +}