diff --git a/ios/Ascently.xcodeproj/project.pbxproj b/ios/Ascently.xcodeproj/project.pbxproj index 20d1567..c9fc1c4 100644 --- a/ios/Ascently.xcodeproj/project.pbxproj +++ b/ios/Ascently.xcodeproj/project.pbxproj @@ -484,7 +484,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -536,7 +536,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 18.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index ba2caac..5ffe841 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/AppIntents/SessionIntentSupport.swift b/ios/Ascently/AppIntents/SessionIntentSupport.swift index 5f4e70b..b7d5425 100644 --- a/ios/Ascently/AppIntents/SessionIntentSupport.swift +++ b/ios/Ascently/AppIntents/SessionIntentSupport.swift @@ -41,7 +41,7 @@ final class SessionIntentController { func startSessionWithLastUsedGym() async throws -> SessionIntentSummary { // Wait for data to load if dataManager.gyms.isEmpty { - try? await Task.sleep(nanoseconds: 500_000_000) + try? await Task.sleep(for: .milliseconds(500)) } guard let lastGym = dataManager.getLastUsedGym() else { @@ -49,7 +49,7 @@ final class SessionIntentController { throw SessionIntentError.noRecentGym } - guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else { + guard let startedSession = await dataManager.startSession(gymId: lastGym.id) else { logFailure(.failedToStartSession, context: "Data manager failed to create new session") throw SessionIntentError.failedToStartSession } @@ -68,7 +68,7 @@ final class SessionIntentController { throw SessionIntentError.noActiveSession } - guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else { + guard let completedSession = await dataManager.endSession(activeSession.id) else { logFailure( .failedToEndSession, context: "Data manager failed to complete active session") throw SessionIntentError.failedToEndSession @@ -97,7 +97,7 @@ final class SessionIntentController { func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) { // Wait for data to load if dataManager.gyms.isEmpty { - try? await Task.sleep(nanoseconds: 500_000_000) + try? await Task.sleep(for: .milliseconds(500)) } if dataManager.activeSession != nil { diff --git a/ios/Ascently/AppIntents/ToggleSessionIntent.swift b/ios/Ascently/AppIntents/ToggleSessionIntent.swift index 1c7a2c9..1551e69 100644 --- a/ios/Ascently/AppIntents/ToggleSessionIntent.swift +++ b/ios/Ascently/AppIntents/ToggleSessionIntent.swift @@ -20,14 +20,14 @@ struct ToggleSessionIntent: AppIntent { func perform() async throws -> some IntentResult & ProvidesDialog { // Wait for app initialization - try? await Task.sleep(nanoseconds: 1_000_000_000) + try? await Task.sleep(for: .seconds(1)) let controller = await SessionIntentController() let (summary, wasStarted) = try await controller.toggleSession() if wasStarted { // Wait for Live Activity - try? await Task.sleep(nanoseconds: 500_000_000) + try? await Task.sleep(for: .milliseconds(500)) return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!")) } else { return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!")) diff --git a/ios/Ascently/ContentView.swift b/ios/Ascently/ContentView.swift index a0fb895..54fb15c 100644 --- a/ios/Ascently/ContentView.swift +++ b/ios/Ascently/ContentView.swift @@ -49,7 +49,7 @@ struct ContentView: View { if newPhase == .active { // Add slight delay to ensure app is fully loaded Task { - try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + try? await Task.sleep(for: .milliseconds(200)) dataManager.onAppBecomeActive() // Re-verify health integration when app becomes active await dataManager.healthKitService.verifyAndRestoreIntegration() @@ -96,7 +96,7 @@ struct ContentView: View { AppLogger.info( "App will enter foreground - preparing Live Activity check", tag: "Lifecycle") // Small delay to ensure app is fully active - try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds + try? await Task.sleep(for: .milliseconds(800)) dataManager.onAppBecomeActive() // Re-verify health integration when returning from background await dataManager.healthKitService.verifyAndRestoreIntegration() @@ -112,7 +112,7 @@ struct ContentView: View { Task { @MainActor in AppLogger.info( "App did become active - checking Live Activity status", tag: "Lifecycle") - try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + try? await Task.sleep(for: .milliseconds(300)) dataManager.onAppBecomeActive() await dataManager.healthKitService.verifyAndRestoreIntegration() } diff --git a/ios/Ascently/Services/MusicService.swift b/ios/Ascently/Services/MusicService.swift index 6c2d6a2..8689ffa 100644 --- a/ios/Ascently/Services/MusicService.swift +++ b/ios/Ascently/Services/MusicService.swift @@ -6,7 +6,7 @@ import SwiftUI @MainActor class MusicService: ObservableObject { static let shared = MusicService() - + @Published var isAuthorized = false @Published var playlists: MusicItemCollection = [] @Published var selectedPlaylistId: String? { @@ -33,60 +33,55 @@ class MusicService: ObservableObject { } } @Published var isPlaying = false - + private var cancellables = Set() private var hasStartedSessionPlayback = false private var currentPlaylistTrackIds: Set = [] - + private init() { self.selectedPlaylistId = UserDefaults.standard.string(forKey: "ascently_selected_playlist_id") self.isMusicEnabled = UserDefaults.standard.bool(forKey: "ascently_music_enabled") self.isAutoPlayEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autoplay_enabled") self.isAutoStopEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autostop_enabled") - + if isMusicEnabled { Task { await checkAuthorizationStatus() } } - + setupObservers() } - + private func setupObservers() { SystemMusicPlayer.shared.state.objectWillChange .sink { [weak self] _ in self?.updatePlaybackStatus() } .store(in: &cancellables) - + SystemMusicPlayer.shared.queue.objectWillChange .sink { [weak self] _ in self?.checkQueueConsistency() } .store(in: &cancellables) } - + private func updatePlaybackStatus() { - Task { @MainActor [weak self] in - self?.isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing - } + isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing } - + private func checkQueueConsistency() { guard hasStartedSessionPlayback else { return } - - Task { @MainActor [weak self] in - guard let self = self else { return } - if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry, - let item = currentEntry.item { - if !self.currentPlaylistTrackIds.isEmpty && !self.currentPlaylistTrackIds.contains(item.id) { - self.hasStartedSessionPlayback = false - } + + if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry, + let item = currentEntry.item { + if !currentPlaylistTrackIds.isEmpty && !currentPlaylistTrackIds.contains(item.id) { + hasStartedSessionPlayback = false } } } - + func toggleMusicEnabled(_ enabled: Bool) { isMusicEnabled = enabled if enabled { @@ -95,7 +90,7 @@ class MusicService: ObservableObject { } } } - + func checkAuthorizationStatus() async { let status = await MusicAuthorization.request() self.isAuthorized = status == .authorized @@ -103,7 +98,7 @@ class MusicService: ObservableObject { await fetchPlaylists() } } - + func fetchPlaylists() async { guard isAuthorized else { return } do { @@ -115,20 +110,20 @@ class MusicService: ObservableObject { print("Error fetching playlists: \(error)") } } - + func playSelectedPlaylistIfHeadphonesConnected() { guard isMusicEnabled, isAutoPlayEnabled, let playlistId = selectedPlaylistId else { return } - + if isHeadphonesConnected() { playPlaylist(id: playlistId) } } - + func resetSessionPlaybackState() { hasStartedSessionPlayback = false currentPlaylistTrackIds.removeAll() } - + func playPlaylist(id: String) { print("Attempting to play playlist \(id)") Task { @@ -136,9 +131,9 @@ class MusicService: ObservableObject { if playlists.isEmpty { await fetchPlaylists() } - + var targetPlaylist: Playlist? - + if let playlist = playlists.first(where: { $0.id.rawValue == id }) { targetPlaylist = playlist } else { @@ -147,13 +142,13 @@ class MusicService: ObservableObject { let response = try await request.response() targetPlaylist = response.items.first } - + if let playlist = targetPlaylist { let detailedPlaylist = try await playlist.with([.tracks]) if let tracks = detailedPlaylist.tracks { self.currentPlaylistTrackIds = Set(tracks.map { $0.id }) } - + SystemMusicPlayer.shared.queue = [playlist] try await SystemMusicPlayer.shared.play() hasStartedSessionPlayback = true @@ -163,12 +158,12 @@ class MusicService: ObservableObject { } } } - + func stopPlaybackIfEnabled() { guard isMusicEnabled, isAutoStopEnabled else { return } SystemMusicPlayer.shared.stop() } - + func togglePlayback() { Task { if isPlaying { @@ -182,7 +177,7 @@ class MusicService: ObservableObject { } } } - + private func isHeadphonesConnected() -> Bool { let route = AVAudioSession.sharedInstance().currentRoute return route.outputs.contains { port in diff --git a/ios/Ascently/Services/SyncService.swift b/ios/Ascently/Services/SyncService.swift index 8ce7cb1..9cd8133 100644 --- a/ios/Ascently/Services/SyncService.swift +++ b/ios/Ascently/Services/SyncService.swift @@ -10,7 +10,7 @@ class SyncService: ObservableObject { @Published var isConnected = false @Published var isTesting = false @Published var isOfflineMode = false - + @Published var providerType: SyncProviderType = .server { didSet { updateActiveProvider() @@ -23,8 +23,6 @@ class SyncService: ObservableObject { private let userDefaults = UserDefaults.standard private let logTag = "SyncService" private var syncTask: Task? - private var pendingChanges = false - private let syncDebounceDelay: TimeInterval = 2.0 private enum Keys { static let serverURL = "sync_server_url" @@ -39,7 +37,7 @@ class SyncService: ObservableObject { // Legacy properties for compatibility with SettingsView var serverURL: String { get { userDefaults.string(forKey: Keys.serverURL) ?? "" } - set { + set { userDefaults.set(newValue, forKey: Keys.serverURL) // If active provider is server, it will pick up the change from UserDefaults } @@ -66,28 +64,28 @@ class SyncService: ObservableObject { isConnected = userDefaults.bool(forKey: Keys.isConnected) isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode) - + if let savedType = userDefaults.string(forKey: Keys.providerType), let type = SyncProviderType(rawValue: savedType) { self.providerType = type } else { self.providerType = .server // Default } - + updateActiveProvider() } - + private func updateActiveProvider() { switch providerType { case .server: activeProvider = ServerSyncProvider() case .iCloud: // Placeholder for iCloud provider - activeProvider = nil + activeProvider = nil case .none: activeProvider = nil } - + // Update status based on new provider if let provider = activeProvider { isConnected = provider.isConnected @@ -101,7 +99,7 @@ class SyncService: ObservableObject { AppLogger.info("Sync skipped: Offline mode is enabled.", tag: logTag) return } - + guard let provider = activeProvider else { if providerType == .none { return @@ -127,7 +125,7 @@ class SyncService: ObservableObject { do { try await provider.sync(dataManager: dataManager) - + // Update last sync time // Provider might have updated it in UserDefaults, reload it if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { @@ -144,12 +142,12 @@ class SyncService: ObservableObject { AppLogger.error("Test connection failed: No active provider", tag: logTag) throw SyncError.notConfigured } - + isTesting = true defer { isTesting = false } - + try await provider.testConnection() - + isConnected = provider.isConnected userDefaults.set(isConnected, forKey: Keys.isConnected) } @@ -162,34 +160,19 @@ class SyncService: ObservableObject { return } - if isSyncing { - pendingChanges = true - return - } + guard !isSyncing else { return } syncTask?.cancel() syncTask = Task { - try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000)) - + try? await Task.sleep(for: .seconds(2)) guard !Task.isCancelled else { return } - repeat { - pendingChanges = false - - do { - try await syncWithServer(dataManager: dataManager) - } catch { - await MainActor.run { - self.isSyncing = false - } - return - } - - if pendingChanges { - try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000)) - } - } while pendingChanges && !Task.isCancelled + do { + try await syncWithServer(dataManager: dataManager) + } catch { + self.isSyncing = false + } } } @@ -198,30 +181,26 @@ class SyncService: ObservableObject { syncTask?.cancel() syncTask = nil - pendingChanges = false Task { do { try await syncWithServer(dataManager: dataManager) } catch { - await MainActor.run { - self.isSyncing = false - } + self.isSyncing = false } } } func disconnect() { activeProvider?.disconnect() - + syncTask?.cancel() syncTask = nil - pendingChanges = false isSyncing = false isConnected = false lastSyncTime = nil syncError = nil - + // These are shared keys, so clearing them affects all providers if they use them // But disconnect() is usually user initiated action userDefaults.set(false, forKey: Keys.isConnected) @@ -239,8 +218,7 @@ class SyncService: ObservableObject { userDefaults.removeObject(forKey: Keys.autoSyncEnabled) syncTask?.cancel() syncTask = nil - pendingChanges = false - + activeProvider?.disconnect() } diff --git a/ios/Ascently/ViewModels/ClimbingDataManager.swift b/ios/Ascently/ViewModels/ClimbingDataManager.swift index 4e16ec8..f44f31c 100644 --- a/ios/Ascently/ViewModels/ClimbingDataManager.swift +++ b/ios/Ascently/ViewModels/ClimbingDataManager.swift @@ -93,7 +93,7 @@ class ClimbingDataManager: ObservableObject { .assign(to: &$isSyncing) Task { - try? await Task.sleep(nanoseconds: 2_000_000_000) + try? await Task.sleep(for: .seconds(2)) await performImageMaintenance() // Check if we need to restart Live Activity for active session @@ -417,17 +417,10 @@ class ClimbingDataManager: ObservableObject { return problems.filter { $0.gymId == gymId && $0.isActive } } - func startSession(gymId: UUID, notes: String? = nil) { - Task { @MainActor in - await startSessionAsync(gymId: gymId, notes: notes) - } - } - @discardableResult - func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? { - // End any currently active session before starting a new one + func startSession(gymId: UUID, notes: String? = nil) async -> ClimbSession? { if let currentActive = activeSession { - await endSessionAsync(currentActive.id) + await endSession(currentActive.id) } let newSession = ClimbSession(gymId: gymId, notes: notes) @@ -462,14 +455,8 @@ class ClimbingDataManager: ObservableObject { return newSession } - func endSession(_ sessionId: UUID) { - Task { @MainActor in - await endSessionAsync(sessionId) - } - } - @discardableResult - func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? { + func endSession(_ sessionId: UUID) async -> ClimbSession? { guard let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), let index = sessions.firstIndex(where: { $0.id == sessionId }) @@ -492,7 +479,7 @@ class ClimbingDataManager: ObservableObject { syncService.triggerAutoSync(dataManager: self) await LiveActivityManager.shared.endLiveActivity() - + if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") { musicService.stopPlaybackIfEnabled() } @@ -999,7 +986,7 @@ class ClimbingDataManager: ObservableObject { private func clearMessageAfterDelay() { Task { - try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + try? await Task.sleep(for: .seconds(3)) successMessage = nil errorMessage = nil } @@ -1247,30 +1234,25 @@ extension ClimbingDataManager { func testLiveActivity() { AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData) - // Check Live Activity availability let status = LiveActivityManager.shared.checkLiveActivityAvailability() AppLogger.info(status, tag: LogTag.climbingData) - // Test with dummy data if we have a gym guard let testGym = gyms.first else { AppLogger.error("No gyms available for testing", tag: LogTag.climbingData) 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) + try? await Task.sleep(for: .seconds(2)) await LiveActivityManager.shared.updateLiveActivity( elapsed: 120, totalAttempts: 5, completedProblems: 1) - // Wait then end - try? await Task.sleep(nanoseconds: 5_000_000_000) + try? await Task.sleep(for: .seconds(5)) await LiveActivityManager.shared.endLiveActivity() } } @@ -1379,8 +1361,7 @@ extension ClimbingDataManager { "Attempting to restart dismissed Live Activity for \(gym.name)", tag: LogTag.climbingData) - // Wait a bit before restarting to avoid frequency limits - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds + try? await Task.sleep(for: .seconds(2)) await LiveActivityManager.shared.startLiveActivity( for: activeSession, diff --git a/ios/Ascently/ViewModels/LiveActivityManager.swift b/ios/Ascently/ViewModels/LiveActivityManager.swift index 825256f..ecdc88d 100644 --- a/ios/Ascently/ViewModels/LiveActivityManager.swift +++ b/ios/Ascently/ViewModels/LiveActivityManager.swift @@ -5,15 +5,16 @@ extension Notification.Name { static let liveActivityDismissed = Notification.Name("liveActivityDismissed") } +extension Activity: @unchecked @retroactive Sendable where Attributes: Sendable {} + @MainActor final class LiveActivityManager { static let shared = LiveActivityManager() - private static let logTag = "LiveActivity" + nonisolated private static let logTag = "LiveActivity" private init() {} nonisolated(unsafe) private var currentActivity: Activity? - private var healthCheckTimer: Timer? - private var lastHealthCheck: Date = Date() + private var healthCheckTask: Task? /// Check if there's an active session and restart Live Activity if needed func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { @@ -106,21 +107,19 @@ final class LiveActivityManager { } } - /// Call this to update the Live Activity with new session progress func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async { - guard let currentActivity = currentActivity else { + guard let activity = currentActivity else { AppLogger.warning("No current activity to update", tag: Self.logTag) return } - // Verify the activity is still valid before updating let activities = Activity.activities - let isStillActive = activities.contains { $0.id == currentActivity.id } + let isStillActive = activities.contains { $0.id == activity.id } if !isStillActive { AppLogger.warning( - "Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference", + "Tracked Live Activity \(activity.id) is no longer active, clearing reference", tag: Self.logTag ) self.currentActivity = nil @@ -138,25 +137,19 @@ final class LiveActivityManager { completedProblems: completedProblems ) - nonisolated(unsafe) let activity = currentActivity await activity.update(.init(state: updatedContentState, staleDate: nil)) } - /// Call this when a ClimbSession ends to end the Live Activity func endLiveActivity() async { - // Stop health checks first stopHealthChecks() - // First end the tracked activity if it exists - if let currentActivity { - AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag) - nonisolated(unsafe) let activity = currentActivity + if let activity = currentActivity { + AppLogger.info("Ending tracked Live Activity: \(activity.id)", tag: Self.logTag) await activity.end(nil, dismissalPolicy: .immediate) self.currentActivity = nil AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag) } - // Force end ALL active activities of our type to ensure cleanup AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag) let activities = Activity.activities @@ -203,38 +196,29 @@ final class LiveActivityManager { } } - /// Start periodic health checks for Live Activity func startHealthChecks() { - stopHealthChecks() // Stop any existing timer + stopHealthChecks() AppLogger.debug("Starting Live Activity health checks", tag: Self.logTag) - healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { - [weak self] _ in - Task { @MainActor [weak self] in - await self?.performHealthCheck() + healthCheckTask = Task { + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled else { break } + await performHealthCheck() } } } - /// Stop periodic health checks func stopHealthChecks() { - healthCheckTimer?.invalidate() - healthCheckTimer = nil + healthCheckTask?.cancel() + healthCheckTask = nil AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag) } - /// Perform a health check on the current Live Activity private func performHealthCheck() async { guard let currentActivity = currentActivity else { return } - let now = Date() - let timeSinceLastCheck = now.timeIntervalSince(lastHealthCheck) - - // Only perform health check if it's been at least 25 seconds - guard timeSinceLastCheck >= 25 else { return } - AppLogger.debug("Performing Live Activity health check", tag: Self.logTag) - lastHealthCheck = now let activities = Activity.activities let isStillActive = activities.contains { $0.id == currentActivity.id } @@ -242,18 +226,12 @@ final class LiveActivityManager { if !isStillActive { AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag) self.currentActivity = nil - - // Notify that we need to restart - NotificationCenter.default.post( - name: .liveActivityDismissed, - object: nil - ) + NotificationCenter.default.post(name: .liveActivityDismissed, object: nil) } else { AppLogger.debug("Live Activity health check passed", tag: Self.logTag) } } - /// Get the current activity status for debugging func getCurrentActivityStatus() -> String { let activities = Activity.activities let trackedStatus = currentActivity != nil ? "Tracked" : "None" @@ -262,12 +240,11 @@ final class LiveActivityManager { return "Status: \(trackedStatus) | Active Count: \(actualCount)" } - /// Start periodic updates for Live Activity func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int) { guard currentActivity != nil else { return } - Task { @MainActor in + Task { while currentActivity != nil { let elapsed = Date().timeIntervalSince(session.startTime ?? session.date) await updateLiveActivity( @@ -275,9 +252,7 @@ final class LiveActivityManager { totalAttempts: totalAttempts, completedProblems: completedProblems ) - - // Wait 30 seconds before next update - try? await Task.sleep(nanoseconds: 30_000_000_000) + try? await Task.sleep(for: .seconds(30)) } } } diff --git a/ios/Ascently/Views/AddEdit/AddEditSessionView.swift b/ios/Ascently/Views/AddEdit/AddEditSessionView.swift index 55327cb..60efb3c 100644 --- a/ios/Ascently/Views/AddEdit/AddEditSessionView.swift +++ b/ios/Ascently/Views/AddEdit/AddEditSessionView.swift @@ -123,11 +123,13 @@ struct AddEditSessionView: View { if isEditing, let session = existingSession { let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes) dataManager.updateSession(updatedSession) + dismiss() } else { - dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes) + Task { + await dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes) + dismiss() + } } - - dismiss() } } diff --git a/ios/Ascently/Views/Detail/SessionDetailView.swift b/ios/Ascently/Views/Detail/SessionDetailView.swift index 8b7fb66..01f5723 100644 --- a/ios/Ascently/Views/Detail/SessionDetailView.swift +++ b/ios/Ascently/Views/Detail/SessionDetailView.swift @@ -138,8 +138,10 @@ struct SessionDetailView: View { if let session = session { if session.status == .active { Button("End Session") { - dataManager.endSession(session.id) - dismiss() + Task { + await dataManager.endSession(session.id) + dismiss() + } } .foregroundColor(.orange) } else { diff --git a/ios/Ascently/Views/LiveActivityDebugView.swift b/ios/Ascently/Views/LiveActivityDebugView.swift index 86e79e1..dea8b69 100644 --- a/ios/Ascently/Views/LiveActivityDebugView.swift +++ b/ios/Ascently/Views/LiveActivityDebugView.swift @@ -220,7 +220,7 @@ struct LiveActivityDebugView: View { appendDebugOutput("Live Activity start request sent") // Wait and update - try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + try? await Task.sleep(for: .seconds(3)) appendDebugOutput("Updating Live Activity with test data...") await LiveActivityManager.shared.updateLiveActivity( elapsed: 180, @@ -229,7 +229,7 @@ struct LiveActivityDebugView: View { ) // Another update - try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + try? await Task.sleep(for: .seconds(3)) appendDebugOutput("Second update...") await LiveActivityManager.shared.updateLiveActivity( elapsed: 360, @@ -238,7 +238,7 @@ struct LiveActivityDebugView: View { ) // End after delay - try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + try? await Task.sleep(for: .seconds(5)) appendDebugOutput("Ending Live Activity...") await LiveActivityManager.shared.endLiveActivity() @@ -253,8 +253,10 @@ struct LiveActivityDebugView: View { } appendDebugOutput("Ending current session: \(activeSession.id)") - dataManager.endSession(activeSession.id) - appendDebugOutput("Session ended") + Task { + await dataManager.endSession(activeSession.id) + appendDebugOutput("Session ended") + } } private func forceLiveActivityUpdate() { diff --git a/ios/Ascently/Views/ProblemsView.swift b/ios/Ascently/Views/ProblemsView.swift index bb5dc16..e34d713 100644 --- a/ios/Ascently/Views/ProblemsView.swift +++ b/ios/Ascently/Views/ProblemsView.swift @@ -19,12 +19,8 @@ struct ProblemsView: View { @State private var animationKey = 0 private func updateFilteredProblems() { - Task(priority: .userInitiated) { - let result = await computeFilteredProblems() - // Switch back to the main thread to update the UI - await MainActor.run { - cachedFilteredProblems = result - } + Task { + cachedFilteredProblems = await computeFilteredProblems() } } diff --git a/ios/Ascently/Views/SessionsView.swift b/ios/Ascently/Views/SessionsView.swift index 7c34c73..de1da36 100644 --- a/ios/Ascently/Views/SessionsView.swift +++ b/ios/Ascently/Views/SessionsView.swift @@ -80,7 +80,9 @@ struct SessionsView: View { } else if dataManager.activeSession == nil { Button("Start Session") { if dataManager.gyms.count == 1 { - dataManager.startSession(gymId: dataManager.gyms.first!.id) + Task { + await dataManager.startSession(gymId: dataManager.gyms.first!.id) + } } else { showingAddSession = true } @@ -228,7 +230,9 @@ struct ActiveSessionBanner: View { } Button(action: { - dataManager.endSession(session.id) + Task { + await dataManager.endSession(session.id) + } }) { Image(systemName: "stop.fill") .font(.system(size: 16, weight: .bold)) @@ -327,7 +331,9 @@ struct EmptySessionsView: View { if !dataManager.gyms.isEmpty { Button("Start Session") { if dataManager.gyms.count == 1 { - dataManager.startSession(gymId: dataManager.gyms.first!.id) + Task { + await dataManager.startSession(gymId: dataManager.gyms.first!.id) + } } else { showingAddSession = true } diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index 9c23fee..13ca873 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -84,11 +84,11 @@ extension SheetType: Identifiable { struct AppearanceSection: View { @EnvironmentObject var themeManager: ThemeManager - + let columns = [ GridItem(.adaptive(minimum: 44)) ] - + var body: some View { Section("Appearance") { VStack(alignment: .leading, spacing: 12) { @@ -96,7 +96,7 @@ struct AppearanceSection: View { .font(.caption) .foregroundColor(.secondary) .textCase(.uppercase) - + LazyVGrid(columns: columns, spacing: 12) { ForEach(ThemeManager.presetColors, id: \.self) { color in Circle() @@ -123,7 +123,7 @@ struct AppearanceSection: View { } .padding(.vertical, 8) } - + if !isSelected(.blue) { Button("Reset to Default") { withAnimation { @@ -134,17 +134,17 @@ struct AppearanceSection: View { } } } - + private func isSelected(_ color: Color) -> Bool { // Compare using UIColor to handle different Color initializers let selectedUIColor = UIColor(themeManager.accentColor) let targetUIColor = UIColor(color) - + // Simple equality check might fail for some system colors, so we check components if needed // But usually UIColor equality is robust enough for system colors return selectedUIColor == targetUIColor } - + private func colorDescription(for color: Color) -> String { switch color { case .blue: return "Blue" @@ -474,8 +474,8 @@ struct ExportDataView: View { } private func createTempFile() { - let logTag = Self.logTag // Capture before entering background queue - DispatchQueue.global(qos: .userInitiated).async { + let logTag = Self.logTag + Task.detached(priority: .userInitiated) { do { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -489,28 +489,23 @@ struct ExportDataView: View { for: .documentDirectory, in: .userDomainMask ).first else { - Task { @MainActor in + await MainActor.run { AppLogger.error("Could not access Documents directory", tag: logTag) - } - DispatchQueue.main.async { self.isCreatingFile = false } return } let fileURL = documentsURL.appendingPathComponent(filename) - // Write the ZIP data to the file try data.write(to: fileURL) - DispatchQueue.main.async { + await MainActor.run { self.tempFileURL = fileURL self.isCreatingFile = false } } catch { - Task { @MainActor in + await MainActor.run { AppLogger.error("Failed to create export file: \(error)", tag: logTag) - } - DispatchQueue.main.async { self.isCreatingFile = false } } @@ -809,12 +804,12 @@ struct SyncSettingsView: View { syncService.serverURL = newURL syncService.authToken = newToken - + // Ensure provider type is set to server if syncService.providerType != .server { syncService.providerType = .server } - + dismiss() } .fontWeight(.semibold) @@ -1116,7 +1111,7 @@ struct HealthKitSection: View { struct MusicSection: View { @EnvironmentObject var musicService: MusicService - + var body: some View { Section { Toggle(isOn: Binding( @@ -1129,7 +1124,7 @@ struct MusicSection: View { Text("Apple Music Integration") } } - + if musicService.isMusicEnabled { if !musicService.isAuthorized { Button("Connect Apple Music") { @@ -1140,14 +1135,14 @@ struct MusicSection: View { } else { Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled) Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled) - + Picker("Playlist", selection: $musicService.selectedPlaylistId) { Text("None").tag(nil as String?) ForEach(musicService.playlists, id: \.id) { playlist in Text(playlist.name).tag(playlist.id.rawValue as String?) } } - + if musicService.isAutoPlayEnabled { Text("Music will only auto-play if headphones are connected when you start a session.") .font(.caption)