Proper 1.0 release for iOS. Pending App Store submission.
This commit is contained in:
143
ios/ClimbingActivityWidget/ClimbingActivityWidget.swift
Normal file
143
ios/ClimbingActivityWidget/ClimbingActivityWidget.swift
Normal file
@@ -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<SessionActivityAttributes>
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/ClimbingActivityWidget/Info.plist
Normal file
31
ios/ClimbingActivityWidget/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>ClimbingActivityWidget</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
50
ios/OpenClimb.xcodeproj/LiveActivityManager.swift
Normal file
50
ios/OpenClimb.xcodeproj/LiveActivityManager.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveActivityManager {
|
||||||
|
static let shared = LiveActivityManager()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private var currentActivity: Activity<SessionActivityAttributes>?
|
||||||
|
|
||||||
|
/// 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<SessionActivityAttributes>.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,44 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
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 */
|
/* Begin PBXFileReference section */
|
||||||
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -18,6 +54,13 @@
|
|||||||
);
|
);
|
||||||
target = D24C19672E75002A0045894C /* OpenClimb */;
|
target = D24C19672E75002A0045894C /* OpenClimb */;
|
||||||
};
|
};
|
||||||
|
D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -29,6 +72,14 @@
|
|||||||
path = OpenClimb;
|
path = OpenClimb;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
D2FE94A42E78FEE1008CDB25 /* Exceptions for "SessionStatusLive" folder in "SessionStatusLiveExtension" target */,
|
||||||
|
);
|
||||||
|
path = SessionStatusLive;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -36,6 +87,17 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -46,6 +108,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D24C196A2E75002A0045894C /* OpenClimb */,
|
D24C196A2E75002A0045894C /* OpenClimb */,
|
||||||
|
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
|
||||||
|
D2FE947F2E78E958008CDB25 /* Frameworks */,
|
||||||
D24C19692E75002A0045894C /* Products */,
|
D24C19692E75002A0045894C /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -54,10 +118,21 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D24C19682E75002A0045894C /* OpenClimb.app */,
|
D24C19682E75002A0045894C /* OpenClimb.app */,
|
||||||
|
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D2FE947F2E78E958008CDB25 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D2FE94802E78E958008CDB25 /* ActivityKit.framework */,
|
||||||
|
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */,
|
||||||
|
D2FE948E2E78FEE0008CDB25 /* SwiftUI.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -68,10 +143,12 @@
|
|||||||
D24C19642E75002A0045894C /* Sources */,
|
D24C19642E75002A0045894C /* Sources */,
|
||||||
D24C19652E75002A0045894C /* Frameworks */,
|
D24C19652E75002A0045894C /* Frameworks */,
|
||||||
D24C19662E75002A0045894C /* Resources */,
|
D24C19662E75002A0045894C /* Resources */,
|
||||||
|
D2FE94A52E78FEE1008CDB25 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
D24C196A2E75002A0045894C /* OpenClimb */,
|
D24C196A2E75002A0045894C /* OpenClimb */,
|
||||||
@@ -83,6 +160,28 @@
|
|||||||
productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
|
productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -96,6 +195,9 @@
|
|||||||
D24C19672E75002A0045894C = {
|
D24C19672E75002A0045894C = {
|
||||||
CreatedOnToolsVersion = 26.0;
|
CreatedOnToolsVersion = 26.0;
|
||||||
};
|
};
|
||||||
|
D2FE948A2E78FEE0008CDB25 = {
|
||||||
|
CreatedOnToolsVersion = 26.0;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */;
|
buildConfigurationList = D24C19632E75002A0045894C /* Build configuration list for PBXProject "OpenClimb" */;
|
||||||
@@ -113,6 +215,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
D24C19672E75002A0045894C /* OpenClimb */,
|
D24C19672E75002A0045894C /* OpenClimb */,
|
||||||
|
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -125,6 +228,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D2FE94892E78FEE0008CDB25 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -135,8 +245,23 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D2FE94872E78FEE0008CDB25 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */;
|
||||||
|
targetProxy = D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
D24C19712E75002A0045894C /* Debug */ = {
|
D24C19712E75002A0045894C /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -335,6 +460,64 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -356,6 +539,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
D2FE94A22E78FEE1008CDB25 /* Debug */,
|
||||||
|
D2FE94A32E78FEE1008CDB25 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = D24C19602E75002A0045894C /* Project object */;
|
rootObject = D24C19602E75002A0045894C /* Project object */;
|
||||||
|
|||||||
@@ -4,4 +4,10 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "self:">
|
location = "self:">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:LiveActivityManager.swift">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:SessionLiveActivityWidget.swift">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,11 @@
|
|||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>SessionStatusLiveExtension.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var dataManager = ClimbingDataManager()
|
@StateObject private var dataManager = ClimbingDataManager()
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -43,6 +43,11 @@ struct ContentView: View {
|
|||||||
.tag(4)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.environmentObject(dataManager)
|
.environmentObject(dataManager)
|
||||||
|
.onChange(of: scenePhase) { newPhase in
|
||||||
|
if newPhase == .active {
|
||||||
|
dataManager.onAppBecomeActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if let message = dataManager.successMessage {
|
if let message = dataManager.successMessage {
|
||||||
SuccessMessageView(message: message)
|
SuccessMessageView(message: message)
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
19
ios/OpenClimb/Models/ActivityAttributes.swift
Normal file
19
ios/OpenClimb/Models/ActivityAttributes.swift
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
import WidgetKit
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class ClimbingDataManager: ObservableObject {
|
class ClimbingDataManager: ObservableObject {
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
@Published var successMessage: String?
|
@Published var successMessage: String?
|
||||||
|
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let sharedUserDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb")
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
@@ -35,6 +40,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
await performImageMaintenance()
|
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() {
|
private func saveGyms() {
|
||||||
if let data = try? encoder.encode(gyms) {
|
if let data = try? encoder.encode(gyms) {
|
||||||
userDefaults.set(data, forKey: Keys.gyms)
|
userDefaults.set(data, forKey: Keys.gyms)
|
||||||
|
// Share with widget
|
||||||
|
sharedUserDefaults?.set(data, forKey: Keys.gyms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveProblems() {
|
private func saveProblems() {
|
||||||
if let data = try? encoder.encode(problems) {
|
if let data = try? encoder.encode(problems) {
|
||||||
userDefaults.set(data, forKey: Keys.problems)
|
userDefaults.set(data, forKey: Keys.problems)
|
||||||
|
// Share with widget
|
||||||
|
sharedUserDefaults?.set(data, forKey: Keys.problems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveSessions() {
|
private func saveSessions() {
|
||||||
if let data = try? encoder.encode(sessions) {
|
if let data = try? encoder.encode(sessions) {
|
||||||
userDefaults.set(data, forKey: Keys.sessions)
|
userDefaults.set(data, forKey: Keys.sessions)
|
||||||
|
// Share with widget
|
||||||
|
sharedUserDefaults?.set(data, forKey: Keys.sessions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAttempts() {
|
private func saveAttempts() {
|
||||||
if let data = try? encoder.encode(attempts) {
|
if let data = try? encoder.encode(attempts) {
|
||||||
userDefaults.set(data, forKey: Keys.attempts)
|
userDefaults.set(data, forKey: Keys.attempts)
|
||||||
|
// Share with widget
|
||||||
|
sharedUserDefaults?.set(data, forKey: Keys.attempts)
|
||||||
|
// Update widget timeline
|
||||||
|
updateWidgetTimeline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +234,14 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
successMessage = "Session started successfully"
|
successMessage = "Session started successfully"
|
||||||
clearMessageAfterDelay()
|
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) {
|
func endSession(_ sessionId: UUID) {
|
||||||
@@ -234,6 +260,11 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
saveSessions()
|
saveSessions()
|
||||||
successMessage = "Session completed successfully"
|
successMessage = "Session completed successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// MARK: - End Live Activity after session ends
|
||||||
|
Task {
|
||||||
|
await LiveActivityManager.shared.endLiveActivity()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +280,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
saveSessions()
|
saveSessions()
|
||||||
successMessage = "Session updated successfully"
|
successMessage = "Session updated successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Update Live Activity when session updates
|
||||||
|
updateLiveActivityForActiveSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +324,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
successMessage = "Attempt logged successfully"
|
successMessage = "Attempt logged successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Update Live Activity when new attempt is added
|
||||||
|
updateLiveActivityForActiveSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAttempt(_ attempt: Attempt) {
|
func updateAttempt(_ attempt: Attempt) {
|
||||||
@@ -298,6 +335,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
saveAttempts()
|
saveAttempts()
|
||||||
successMessage = "Attempt updated successfully"
|
successMessage = "Attempt updated successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Update Live Activity when attempt is updated
|
||||||
|
updateLiveActivityForActiveSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +346,9 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
saveAttempts()
|
saveAttempts()
|
||||||
successMessage = "Attempt deleted successfully"
|
successMessage = "Attempt deleted successfully"
|
||||||
clearMessageAfterDelay()
|
clearMessageAfterDelay()
|
||||||
|
|
||||||
|
// Update Live Activity when attempt is deleted
|
||||||
|
updateLiveActivityForActiveSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func attempts(forSession sessionId: UUID) -> [Attempt] {
|
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 {
|
private func validateImportData(_ importData: ClimbDataExport) throws {
|
||||||
if importData.gyms.isEmpty {
|
if importData.gyms.isEmpty {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|||||||
146
ios/OpenClimb/ViewModels/LiveActivityManager.swift
Normal file
146
ios/OpenClimb/ViewModels/LiveActivityManager.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveActivityManager {
|
||||||
|
static let shared = LiveActivityManager()
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private var currentActivity: Activity<SessionActivityAttributes>?
|
||||||
|
|
||||||
|
/// 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<SessionActivityAttributes>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
280
ios/OpenClimb/Views/LiveActivityDebugView.swift
Normal file
280
ios/OpenClimb/Views/LiveActivityDebugView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
@@ -17,6 +16,8 @@ struct SettingsView: View {
|
|||||||
activeSheet: $activeSheet
|
activeSheet: $activeSheet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LiveActivitySection()
|
||||||
|
|
||||||
ImageStorageSection()
|
ImageStorageSection()
|
||||||
|
|
||||||
AppInfoSection()
|
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 {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(ClimbingDataManager.preview)
|
.environmentObject(ClimbingDataManager.preview)
|
||||||
|
|||||||
18
ios/SessionStatusLive/AppIntent.swift
Normal file
18
ios/SessionStatusLive/AppIntent.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
ios/SessionStatusLive/Assets.xcassets/Contents.json
Normal file
6
ios/SessionStatusLive/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ios/SessionStatusLive/Info.plist
Normal file
11
ios/SessionStatusLive/Info.plist
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
381
ios/SessionStatusLive/SessionStatusLive.swift
Normal file
381
ios/SessionStatusLive/SessionStatusLive.swift
Normal file
@@ -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<ClimbingStatsEntry>) -> 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"
|
||||||
|
)
|
||||||
|
}
|
||||||
18
ios/SessionStatusLive/SessionStatusLiveBundle.swift
Normal file
18
ios/SessionStatusLive/SessionStatusLiveBundle.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
ios/SessionStatusLive/SessionStatusLiveControl.swift
Normal file
77
ios/SessionStatusLive/SessionStatusLiveControl.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
223
ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift
Normal file
223
ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift
Normal file
@@ -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<SessionActivityAttributes>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user