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 dac7ec2..4811c96 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb.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 +}