Proper 1.0 release for iOS. Pending App Store submission.
This commit is contained in:
@@ -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(
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user