Improve concurrency model for iOS

This commit is contained in:
2026-01-08 19:18:44 -07:00
parent 1c47dd93b0
commit ec63d7c58f
15 changed files with 137 additions and 205 deletions

View File

@@ -484,7 +484,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6; IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -536,7 +536,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6; IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@@ -41,7 +41,7 @@ final class SessionIntentController {
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary { func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Wait for data to load // Wait for data to load
if dataManager.gyms.isEmpty { 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 { guard let lastGym = dataManager.getLastUsedGym() else {
@@ -49,7 +49,7 @@ final class SessionIntentController {
throw SessionIntentError.noRecentGym 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") logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession throw SessionIntentError.failedToStartSession
} }
@@ -68,7 +68,7 @@ final class SessionIntentController {
throw SessionIntentError.noActiveSession throw SessionIntentError.noActiveSession
} }
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else { guard let completedSession = await dataManager.endSession(activeSession.id) else {
logFailure( logFailure(
.failedToEndSession, context: "Data manager failed to complete active session") .failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession throw SessionIntentError.failedToEndSession
@@ -97,7 +97,7 @@ final class SessionIntentController {
func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) { func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) {
// Wait for data to load // Wait for data to load
if dataManager.gyms.isEmpty { if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(for: .milliseconds(500))
} }
if dataManager.activeSession != nil { if dataManager.activeSession != nil {

View File

@@ -20,14 +20,14 @@ struct ToggleSessionIntent: AppIntent {
func perform() async throws -> some IntentResult & ProvidesDialog { func perform() async throws -> some IntentResult & ProvidesDialog {
// Wait for app initialization // 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 controller = await SessionIntentController()
let (summary, wasStarted) = try await controller.toggleSession() let (summary, wasStarted) = try await controller.toggleSession()
if wasStarted { if wasStarted {
// Wait for Live Activity // 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!")) return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!"))
} else { } else {
return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!")) return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!"))

View File

@@ -49,7 +49,7 @@ struct ContentView: View {
if newPhase == .active { if newPhase == .active {
// Add slight delay to ensure app is fully loaded // Add slight delay to ensure app is fully loaded
Task { Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds try? await Task.sleep(for: .milliseconds(200))
dataManager.onAppBecomeActive() dataManager.onAppBecomeActive()
// Re-verify health integration when app becomes active // Re-verify health integration when app becomes active
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
@@ -96,7 +96,7 @@ struct ContentView: View {
AppLogger.info( AppLogger.info(
"App will enter foreground - preparing Live Activity check", tag: "Lifecycle") "App will enter foreground - preparing Live Activity check", tag: "Lifecycle")
// Small delay to ensure app is fully active // 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() dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background // Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
@@ -112,7 +112,7 @@ struct ContentView: View {
Task { @MainActor in Task { @MainActor in
AppLogger.info( AppLogger.info(
"App did become active - checking Live Activity status", tag: "Lifecycle") "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() dataManager.onAppBecomeActive()
await dataManager.healthKitService.verifyAndRestoreIntegration() await dataManager.healthKitService.verifyAndRestoreIntegration()
} }

View File

@@ -6,7 +6,7 @@ import SwiftUI
@MainActor @MainActor
class MusicService: ObservableObject { class MusicService: ObservableObject {
static let shared = MusicService() static let shared = MusicService()
@Published var isAuthorized = false @Published var isAuthorized = false
@Published var playlists: MusicItemCollection<Playlist> = [] @Published var playlists: MusicItemCollection<Playlist> = []
@Published var selectedPlaylistId: String? { @Published var selectedPlaylistId: String? {
@@ -33,60 +33,55 @@ class MusicService: ObservableObject {
} }
} }
@Published var isPlaying = false @Published var isPlaying = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var hasStartedSessionPlayback = false private var hasStartedSessionPlayback = false
private var currentPlaylistTrackIds: Set<MusicItemID> = [] private var currentPlaylistTrackIds: Set<MusicItemID> = []
private init() { private init() {
self.selectedPlaylistId = UserDefaults.standard.string(forKey: "ascently_selected_playlist_id") self.selectedPlaylistId = UserDefaults.standard.string(forKey: "ascently_selected_playlist_id")
self.isMusicEnabled = UserDefaults.standard.bool(forKey: "ascently_music_enabled") self.isMusicEnabled = UserDefaults.standard.bool(forKey: "ascently_music_enabled")
self.isAutoPlayEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autoplay_enabled") self.isAutoPlayEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autoplay_enabled")
self.isAutoStopEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autostop_enabled") self.isAutoStopEnabled = UserDefaults.standard.bool(forKey: "ascently_music_autostop_enabled")
if isMusicEnabled { if isMusicEnabled {
Task { Task {
await checkAuthorizationStatus() await checkAuthorizationStatus()
} }
} }
setupObservers() setupObservers()
} }
private func setupObservers() { private func setupObservers() {
SystemMusicPlayer.shared.state.objectWillChange SystemMusicPlayer.shared.state.objectWillChange
.sink { [weak self] _ in .sink { [weak self] _ in
self?.updatePlaybackStatus() self?.updatePlaybackStatus()
} }
.store(in: &cancellables) .store(in: &cancellables)
SystemMusicPlayer.shared.queue.objectWillChange SystemMusicPlayer.shared.queue.objectWillChange
.sink { [weak self] _ in .sink { [weak self] _ in
self?.checkQueueConsistency() self?.checkQueueConsistency()
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
private func updatePlaybackStatus() { private func updatePlaybackStatus() {
Task { @MainActor [weak self] in isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
self?.isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
}
} }
private func checkQueueConsistency() { private func checkQueueConsistency() {
guard hasStartedSessionPlayback else { return } guard hasStartedSessionPlayback else { return }
Task { @MainActor [weak self] in if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry,
guard let self = self else { return } let item = currentEntry.item {
if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry, if !currentPlaylistTrackIds.isEmpty && !currentPlaylistTrackIds.contains(item.id) {
let item = currentEntry.item { hasStartedSessionPlayback = false
if !self.currentPlaylistTrackIds.isEmpty && !self.currentPlaylistTrackIds.contains(item.id) {
self.hasStartedSessionPlayback = false
}
} }
} }
} }
func toggleMusicEnabled(_ enabled: Bool) { func toggleMusicEnabled(_ enabled: Bool) {
isMusicEnabled = enabled isMusicEnabled = enabled
if enabled { if enabled {
@@ -95,7 +90,7 @@ class MusicService: ObservableObject {
} }
} }
} }
func checkAuthorizationStatus() async { func checkAuthorizationStatus() async {
let status = await MusicAuthorization.request() let status = await MusicAuthorization.request()
self.isAuthorized = status == .authorized self.isAuthorized = status == .authorized
@@ -103,7 +98,7 @@ class MusicService: ObservableObject {
await fetchPlaylists() await fetchPlaylists()
} }
} }
func fetchPlaylists() async { func fetchPlaylists() async {
guard isAuthorized else { return } guard isAuthorized else { return }
do { do {
@@ -115,20 +110,20 @@ class MusicService: ObservableObject {
print("Error fetching playlists: \(error)") print("Error fetching playlists: \(error)")
} }
} }
func playSelectedPlaylistIfHeadphonesConnected() { func playSelectedPlaylistIfHeadphonesConnected() {
guard isMusicEnabled, isAutoPlayEnabled, let playlistId = selectedPlaylistId else { return } guard isMusicEnabled, isAutoPlayEnabled, let playlistId = selectedPlaylistId else { return }
if isHeadphonesConnected() { if isHeadphonesConnected() {
playPlaylist(id: playlistId) playPlaylist(id: playlistId)
} }
} }
func resetSessionPlaybackState() { func resetSessionPlaybackState() {
hasStartedSessionPlayback = false hasStartedSessionPlayback = false
currentPlaylistTrackIds.removeAll() currentPlaylistTrackIds.removeAll()
} }
func playPlaylist(id: String) { func playPlaylist(id: String) {
print("Attempting to play playlist \(id)") print("Attempting to play playlist \(id)")
Task { Task {
@@ -136,9 +131,9 @@ class MusicService: ObservableObject {
if playlists.isEmpty { if playlists.isEmpty {
await fetchPlaylists() await fetchPlaylists()
} }
var targetPlaylist: Playlist? var targetPlaylist: Playlist?
if let playlist = playlists.first(where: { $0.id.rawValue == id }) { if let playlist = playlists.first(where: { $0.id.rawValue == id }) {
targetPlaylist = playlist targetPlaylist = playlist
} else { } else {
@@ -147,13 +142,13 @@ class MusicService: ObservableObject {
let response = try await request.response() let response = try await request.response()
targetPlaylist = response.items.first targetPlaylist = response.items.first
} }
if let playlist = targetPlaylist { if let playlist = targetPlaylist {
let detailedPlaylist = try await playlist.with([.tracks]) let detailedPlaylist = try await playlist.with([.tracks])
if let tracks = detailedPlaylist.tracks { if let tracks = detailedPlaylist.tracks {
self.currentPlaylistTrackIds = Set(tracks.map { $0.id }) self.currentPlaylistTrackIds = Set(tracks.map { $0.id })
} }
SystemMusicPlayer.shared.queue = [playlist] SystemMusicPlayer.shared.queue = [playlist]
try await SystemMusicPlayer.shared.play() try await SystemMusicPlayer.shared.play()
hasStartedSessionPlayback = true hasStartedSessionPlayback = true
@@ -163,12 +158,12 @@ class MusicService: ObservableObject {
} }
} }
} }
func stopPlaybackIfEnabled() { func stopPlaybackIfEnabled() {
guard isMusicEnabled, isAutoStopEnabled else { return } guard isMusicEnabled, isAutoStopEnabled else { return }
SystemMusicPlayer.shared.stop() SystemMusicPlayer.shared.stop()
} }
func togglePlayback() { func togglePlayback() {
Task { Task {
if isPlaying { if isPlaying {
@@ -182,7 +177,7 @@ class MusicService: ObservableObject {
} }
} }
} }
private func isHeadphonesConnected() -> Bool { private func isHeadphonesConnected() -> Bool {
let route = AVAudioSession.sharedInstance().currentRoute let route = AVAudioSession.sharedInstance().currentRoute
return route.outputs.contains { port in return route.outputs.contains { port in

View File

@@ -10,7 +10,7 @@ class SyncService: ObservableObject {
@Published var isConnected = false @Published var isConnected = false
@Published var isTesting = false @Published var isTesting = false
@Published var isOfflineMode = false @Published var isOfflineMode = false
@Published var providerType: SyncProviderType = .server { @Published var providerType: SyncProviderType = .server {
didSet { didSet {
updateActiveProvider() updateActiveProvider()
@@ -23,8 +23,6 @@ class SyncService: ObservableObject {
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let logTag = "SyncService" private let logTag = "SyncService"
private var syncTask: Task<Void, Never>? private var syncTask: Task<Void, Never>?
private var pendingChanges = false
private let syncDebounceDelay: TimeInterval = 2.0
private enum Keys { private enum Keys {
static let serverURL = "sync_server_url" static let serverURL = "sync_server_url"
@@ -39,7 +37,7 @@ class SyncService: ObservableObject {
// Legacy properties for compatibility with SettingsView // Legacy properties for compatibility with SettingsView
var serverURL: String { var serverURL: String {
get { userDefaults.string(forKey: Keys.serverURL) ?? "" } get { userDefaults.string(forKey: Keys.serverURL) ?? "" }
set { set {
userDefaults.set(newValue, forKey: Keys.serverURL) userDefaults.set(newValue, forKey: Keys.serverURL)
// If active provider is server, it will pick up the change from UserDefaults // 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) isConnected = userDefaults.bool(forKey: Keys.isConnected)
isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true
isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode) isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode)
if let savedType = userDefaults.string(forKey: Keys.providerType), if let savedType = userDefaults.string(forKey: Keys.providerType),
let type = SyncProviderType(rawValue: savedType) { let type = SyncProviderType(rawValue: savedType) {
self.providerType = type self.providerType = type
} else { } else {
self.providerType = .server // Default self.providerType = .server // Default
} }
updateActiveProvider() updateActiveProvider()
} }
private func updateActiveProvider() { private func updateActiveProvider() {
switch providerType { switch providerType {
case .server: case .server:
activeProvider = ServerSyncProvider() activeProvider = ServerSyncProvider()
case .iCloud: case .iCloud:
// Placeholder for iCloud provider // Placeholder for iCloud provider
activeProvider = nil activeProvider = nil
case .none: case .none:
activeProvider = nil activeProvider = nil
} }
// Update status based on new provider // Update status based on new provider
if let provider = activeProvider { if let provider = activeProvider {
isConnected = provider.isConnected isConnected = provider.isConnected
@@ -101,7 +99,7 @@ class SyncService: ObservableObject {
AppLogger.info("Sync skipped: Offline mode is enabled.", tag: logTag) AppLogger.info("Sync skipped: Offline mode is enabled.", tag: logTag)
return return
} }
guard let provider = activeProvider else { guard let provider = activeProvider else {
if providerType == .none { if providerType == .none {
return return
@@ -127,7 +125,7 @@ class SyncService: ObservableObject {
do { do {
try await provider.sync(dataManager: dataManager) try await provider.sync(dataManager: dataManager)
// Update last sync time // Update last sync time
// Provider might have updated it in UserDefaults, reload it // Provider might have updated it in UserDefaults, reload it
if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { 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) AppLogger.error("Test connection failed: No active provider", tag: logTag)
throw SyncError.notConfigured throw SyncError.notConfigured
} }
isTesting = true isTesting = true
defer { isTesting = false } defer { isTesting = false }
try await provider.testConnection() try await provider.testConnection()
isConnected = provider.isConnected isConnected = provider.isConnected
userDefaults.set(isConnected, forKey: Keys.isConnected) userDefaults.set(isConnected, forKey: Keys.isConnected)
} }
@@ -162,34 +160,19 @@ class SyncService: ObservableObject {
return return
} }
if isSyncing { guard !isSyncing else { return }
pendingChanges = true
return
}
syncTask?.cancel() syncTask?.cancel()
syncTask = Task { 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 } guard !Task.isCancelled else { return }
repeat { do {
pendingChanges = false try await syncWithServer(dataManager: dataManager)
} catch {
do { self.isSyncing = false
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
} }
} }
@@ -198,30 +181,26 @@ class SyncService: ObservableObject {
syncTask?.cancel() syncTask?.cancel()
syncTask = nil syncTask = nil
pendingChanges = false
Task { Task {
do { do {
try await syncWithServer(dataManager: dataManager) try await syncWithServer(dataManager: dataManager)
} catch { } catch {
await MainActor.run { self.isSyncing = false
self.isSyncing = false
}
} }
} }
} }
func disconnect() { func disconnect() {
activeProvider?.disconnect() activeProvider?.disconnect()
syncTask?.cancel() syncTask?.cancel()
syncTask = nil syncTask = nil
pendingChanges = false
isSyncing = false isSyncing = false
isConnected = false isConnected = false
lastSyncTime = nil lastSyncTime = nil
syncError = nil syncError = nil
// These are shared keys, so clearing them affects all providers if they use them // These are shared keys, so clearing them affects all providers if they use them
// But disconnect() is usually user initiated action // But disconnect() is usually user initiated action
userDefaults.set(false, forKey: Keys.isConnected) userDefaults.set(false, forKey: Keys.isConnected)
@@ -239,8 +218,7 @@ class SyncService: ObservableObject {
userDefaults.removeObject(forKey: Keys.autoSyncEnabled) userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
syncTask?.cancel() syncTask?.cancel()
syncTask = nil syncTask = nil
pendingChanges = false
activeProvider?.disconnect() activeProvider?.disconnect()
} }

View File

@@ -93,7 +93,7 @@ class ClimbingDataManager: ObservableObject {
.assign(to: &$isSyncing) .assign(to: &$isSyncing)
Task { Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(for: .seconds(2))
await performImageMaintenance() await performImageMaintenance()
// Check if we need to restart Live Activity for active session // 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 } 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 @discardableResult
func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? { func startSession(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
// End any currently active session before starting a new one
if let currentActive = activeSession { if let currentActive = activeSession {
await endSessionAsync(currentActive.id) await endSession(currentActive.id)
} }
let newSession = ClimbSession(gymId: gymId, notes: notes) let newSession = ClimbSession(gymId: gymId, notes: notes)
@@ -462,14 +455,8 @@ class ClimbingDataManager: ObservableObject {
return newSession return newSession
} }
func endSession(_ sessionId: UUID) {
Task { @MainActor in
await endSessionAsync(sessionId)
}
}
@discardableResult @discardableResult
func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? { func endSession(_ sessionId: UUID) async -> ClimbSession? {
guard guard
let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }), let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
let index = sessions.firstIndex(where: { $0.id == sessionId }) let index = sessions.firstIndex(where: { $0.id == sessionId })
@@ -492,7 +479,7 @@ class ClimbingDataManager: ObservableObject {
syncService.triggerAutoSync(dataManager: self) syncService.triggerAutoSync(dataManager: self)
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") { if UserDefaults.standard.bool(forKey: "isAutoStopMusicEnabled") {
musicService.stopPlaybackIfEnabled() musicService.stopPlaybackIfEnabled()
} }
@@ -999,7 +986,7 @@ class ClimbingDataManager: ObservableObject {
private func clearMessageAfterDelay() { private func clearMessageAfterDelay() {
Task { Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds try? await Task.sleep(for: .seconds(3))
successMessage = nil successMessage = nil
errorMessage = nil errorMessage = nil
} }
@@ -1247,30 +1234,25 @@ extension ClimbingDataManager {
func testLiveActivity() { func testLiveActivity() {
AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData) AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData)
// Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability() let status = LiveActivityManager.shared.checkLiveActivityAvailability()
AppLogger.info(status, tag: LogTag.climbingData) AppLogger.info(status, tag: LogTag.climbingData)
// Test with dummy data if we have a gym
guard let testGym = gyms.first else { guard let testGym = gyms.first else {
AppLogger.error("No gyms available for testing", tag: LogTag.climbingData) AppLogger.error("No gyms available for testing", tag: LogTag.climbingData)
return return
} }
// Create a test session
let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity") let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity")
Task { Task {
await LiveActivityManager.shared.startLiveActivity( await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name) for: testSession, gymName: testGym.name)
// Wait a bit then update try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(nanoseconds: 2_000_000_000)
await LiveActivityManager.shared.updateLiveActivity( await LiveActivityManager.shared.updateLiveActivity(
elapsed: 120, totalAttempts: 5, completedProblems: 1) elapsed: 120, totalAttempts: 5, completedProblems: 1)
// Wait then end try? await Task.sleep(for: .seconds(5))
try? await Task.sleep(nanoseconds: 5_000_000_000)
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
} }
} }
@@ -1379,8 +1361,7 @@ extension ClimbingDataManager {
"Attempting to restart dismissed Live Activity for \(gym.name)", "Attempting to restart dismissed Live Activity for \(gym.name)",
tag: LogTag.climbingData) tag: LogTag.climbingData)
// Wait a bit before restarting to avoid frequency limits try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
await LiveActivityManager.shared.startLiveActivity( await LiveActivityManager.shared.startLiveActivity(
for: activeSession, for: activeSession,

View File

@@ -5,15 +5,16 @@ extension Notification.Name {
static let liveActivityDismissed = Notification.Name("liveActivityDismissed") static let liveActivityDismissed = Notification.Name("liveActivityDismissed")
} }
extension Activity: @unchecked @retroactive Sendable where Attributes: Sendable {}
@MainActor @MainActor
final class LiveActivityManager { final class LiveActivityManager {
static let shared = LiveActivityManager() static let shared = LiveActivityManager()
private static let logTag = "LiveActivity" nonisolated private static let logTag = "LiveActivity"
private init() {} private init() {}
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>? nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer? private var healthCheckTask: Task<Void, Never>?
private var lastHealthCheck: Date = Date()
/// Check if there's an active session and restart Live Activity if needed /// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async { 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 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) AppLogger.warning("No current activity to update", tag: Self.logTag)
return return
} }
// Verify the activity is still valid before updating
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == activity.id }
if !isStillActive { if !isStillActive {
AppLogger.warning( 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 tag: Self.logTag
) )
self.currentActivity = nil self.currentActivity = nil
@@ -138,25 +137,19 @@ final class LiveActivityManager {
completedProblems: completedProblems completedProblems: completedProblems
) )
nonisolated(unsafe) let activity = currentActivity
await activity.update(.init(state: updatedContentState, staleDate: nil)) await activity.update(.init(state: updatedContentState, staleDate: nil))
} }
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async { func endLiveActivity() async {
// Stop health checks first
stopHealthChecks() stopHealthChecks()
// First end the tracked activity if it exists if let activity = currentActivity {
if let currentActivity { AppLogger.info("Ending tracked Live Activity: \(activity.id)", tag: Self.logTag)
AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag)
nonisolated(unsafe) let activity = currentActivity
await activity.end(nil, dismissalPolicy: .immediate) await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil self.currentActivity = nil
AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag) 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) AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag)
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
@@ -203,38 +196,29 @@ final class LiveActivityManager {
} }
} }
/// Start periodic health checks for Live Activity
func startHealthChecks() { func startHealthChecks() {
stopHealthChecks() // Stop any existing timer stopHealthChecks()
AppLogger.debug("Starting Live Activity health checks", tag: Self.logTag) AppLogger.debug("Starting Live Activity health checks", tag: Self.logTag)
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { healthCheckTask = Task {
[weak self] _ in while !Task.isCancelled {
Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(30))
await self?.performHealthCheck() guard !Task.isCancelled else { break }
await performHealthCheck()
} }
} }
} }
/// Stop periodic health checks
func stopHealthChecks() { func stopHealthChecks() {
healthCheckTimer?.invalidate() healthCheckTask?.cancel()
healthCheckTimer = nil healthCheckTask = nil
AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag) AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag)
} }
/// Perform a health check on the current Live Activity
private func performHealthCheck() async { private func performHealthCheck() async {
guard let currentActivity = currentActivity else { return } 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) AppLogger.debug("Performing Live Activity health check", tag: Self.logTag)
lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id } let isStillActive = activities.contains { $0.id == currentActivity.id }
@@ -242,18 +226,12 @@ final class LiveActivityManager {
if !isStillActive { if !isStillActive {
AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag) AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag)
self.currentActivity = nil self.currentActivity = nil
NotificationCenter.default.post(name: .liveActivityDismissed, object: nil)
// Notify that we need to restart
NotificationCenter.default.post(
name: .liveActivityDismissed,
object: nil
)
} else { } else {
AppLogger.debug("Live Activity health check passed", tag: Self.logTag) AppLogger.debug("Live Activity health check passed", tag: Self.logTag)
} }
} }
/// Get the current activity status for debugging
func getCurrentActivityStatus() -> String { func getCurrentActivityStatus() -> String {
let activities = Activity<SessionActivityAttributes>.activities let activities = Activity<SessionActivityAttributes>.activities
let trackedStatus = currentActivity != nil ? "Tracked" : "None" let trackedStatus = currentActivity != nil ? "Tracked" : "None"
@@ -262,12 +240,11 @@ final class LiveActivityManager {
return "Status: \(trackedStatus) | Active Count: \(actualCount)" return "Status: \(trackedStatus) | Active Count: \(actualCount)"
} }
/// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int) func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{ {
guard currentActivity != nil else { return } guard currentActivity != nil else { return }
Task { @MainActor in Task {
while currentActivity != nil { while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date) let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity( await updateLiveActivity(
@@ -275,9 +252,7 @@ final class LiveActivityManager {
totalAttempts: totalAttempts, totalAttempts: totalAttempts,
completedProblems: completedProblems completedProblems: completedProblems
) )
try? await Task.sleep(for: .seconds(30))
// Wait 30 seconds before next update
try? await Task.sleep(nanoseconds: 30_000_000_000)
} }
} }
} }

View File

@@ -123,11 +123,13 @@ struct AddEditSessionView: View {
if isEditing, let session = existingSession { if isEditing, let session = existingSession {
let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes) let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
dataManager.updateSession(updatedSession) dataManager.updateSession(updatedSession)
dismiss()
} else { } 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()
} }
} }

View File

@@ -138,8 +138,10 @@ struct SessionDetailView: View {
if let session = session { if let session = session {
if session.status == .active { if session.status == .active {
Button("End Session") { Button("End Session") {
dataManager.endSession(session.id) Task {
dismiss() await dataManager.endSession(session.id)
dismiss()
}
} }
.foregroundColor(.orange) .foregroundColor(.orange)
} else { } else {

View File

@@ -220,7 +220,7 @@ struct LiveActivityDebugView: View {
appendDebugOutput("Live Activity start request sent") appendDebugOutput("Live Activity start request sent")
// Wait and update // 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...") appendDebugOutput("Updating Live Activity with test data...")
await LiveActivityManager.shared.updateLiveActivity( await LiveActivityManager.shared.updateLiveActivity(
elapsed: 180, elapsed: 180,
@@ -229,7 +229,7 @@ struct LiveActivityDebugView: View {
) )
// Another update // Another update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds try? await Task.sleep(for: .seconds(3))
appendDebugOutput("Second update...") appendDebugOutput("Second update...")
await LiveActivityManager.shared.updateLiveActivity( await LiveActivityManager.shared.updateLiveActivity(
elapsed: 360, elapsed: 360,
@@ -238,7 +238,7 @@ struct LiveActivityDebugView: View {
) )
// End after delay // 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...") appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity() await LiveActivityManager.shared.endLiveActivity()
@@ -253,8 +253,10 @@ struct LiveActivityDebugView: View {
} }
appendDebugOutput("Ending current session: \(activeSession.id)") appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id) Task {
appendDebugOutput("Session ended") await dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended")
}
} }
private func forceLiveActivityUpdate() { private func forceLiveActivityUpdate() {

View File

@@ -19,12 +19,8 @@ struct ProblemsView: View {
@State private var animationKey = 0 @State private var animationKey = 0
private func updateFilteredProblems() { private func updateFilteredProblems() {
Task(priority: .userInitiated) { Task {
let result = await computeFilteredProblems() cachedFilteredProblems = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
} }
} }

View File

@@ -80,7 +80,9 @@ struct SessionsView: View {
} else if dataManager.activeSession == nil { } else if dataManager.activeSession == nil {
Button("Start Session") { Button("Start Session") {
if dataManager.gyms.count == 1 { if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id) Task {
await dataManager.startSession(gymId: dataManager.gyms.first!.id)
}
} else { } else {
showingAddSession = true showingAddSession = true
} }
@@ -228,7 +230,9 @@ struct ActiveSessionBanner: View {
} }
Button(action: { Button(action: {
dataManager.endSession(session.id) Task {
await dataManager.endSession(session.id)
}
}) { }) {
Image(systemName: "stop.fill") Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold)) .font(.system(size: 16, weight: .bold))
@@ -327,7 +331,9 @@ struct EmptySessionsView: View {
if !dataManager.gyms.isEmpty { if !dataManager.gyms.isEmpty {
Button("Start Session") { Button("Start Session") {
if dataManager.gyms.count == 1 { if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id) Task {
await dataManager.startSession(gymId: dataManager.gyms.first!.id)
}
} else { } else {
showingAddSession = true showingAddSession = true
} }

View File

@@ -84,11 +84,11 @@ extension SheetType: Identifiable {
struct AppearanceSection: View { struct AppearanceSection: View {
@EnvironmentObject var themeManager: ThemeManager @EnvironmentObject var themeManager: ThemeManager
let columns = [ let columns = [
GridItem(.adaptive(minimum: 44)) GridItem(.adaptive(minimum: 44))
] ]
var body: some View { var body: some View {
Section("Appearance") { Section("Appearance") {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -96,7 +96,7 @@ struct AppearanceSection: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.textCase(.uppercase) .textCase(.uppercase)
LazyVGrid(columns: columns, spacing: 12) { LazyVGrid(columns: columns, spacing: 12) {
ForEach(ThemeManager.presetColors, id: \.self) { color in ForEach(ThemeManager.presetColors, id: \.self) { color in
Circle() Circle()
@@ -123,7 +123,7 @@ struct AppearanceSection: View {
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
if !isSelected(.blue) { if !isSelected(.blue) {
Button("Reset to Default") { Button("Reset to Default") {
withAnimation { withAnimation {
@@ -134,17 +134,17 @@ struct AppearanceSection: View {
} }
} }
} }
private func isSelected(_ color: Color) -> Bool { private func isSelected(_ color: Color) -> Bool {
// Compare using UIColor to handle different Color initializers // Compare using UIColor to handle different Color initializers
let selectedUIColor = UIColor(themeManager.accentColor) let selectedUIColor = UIColor(themeManager.accentColor)
let targetUIColor = UIColor(color) let targetUIColor = UIColor(color)
// Simple equality check might fail for some system colors, so we check components if needed // 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 // But usually UIColor equality is robust enough for system colors
return selectedUIColor == targetUIColor return selectedUIColor == targetUIColor
} }
private func colorDescription(for color: Color) -> String { private func colorDescription(for color: Color) -> String {
switch color { switch color {
case .blue: return "Blue" case .blue: return "Blue"
@@ -474,8 +474,8 @@ struct ExportDataView: View {
} }
private func createTempFile() { private func createTempFile() {
let logTag = Self.logTag // Capture before entering background queue let logTag = Self.logTag
DispatchQueue.global(qos: .userInitiated).async { Task.detached(priority: .userInitiated) {
do { do {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -489,28 +489,23 @@ struct ExportDataView: View {
for: .documentDirectory, in: .userDomainMask for: .documentDirectory, in: .userDomainMask
).first ).first
else { else {
Task { @MainActor in await MainActor.run {
AppLogger.error("Could not access Documents directory", tag: logTag) AppLogger.error("Could not access Documents directory", tag: logTag)
}
DispatchQueue.main.async {
self.isCreatingFile = false self.isCreatingFile = false
} }
return return
} }
let fileURL = documentsURL.appendingPathComponent(filename) let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL) try data.write(to: fileURL)
DispatchQueue.main.async { await MainActor.run {
self.tempFileURL = fileURL self.tempFileURL = fileURL
self.isCreatingFile = false self.isCreatingFile = false
} }
} catch { } catch {
Task { @MainActor in await MainActor.run {
AppLogger.error("Failed to create export file: \(error)", tag: logTag) AppLogger.error("Failed to create export file: \(error)", tag: logTag)
}
DispatchQueue.main.async {
self.isCreatingFile = false self.isCreatingFile = false
} }
} }
@@ -809,12 +804,12 @@ struct SyncSettingsView: View {
syncService.serverURL = newURL syncService.serverURL = newURL
syncService.authToken = newToken syncService.authToken = newToken
// Ensure provider type is set to server // Ensure provider type is set to server
if syncService.providerType != .server { if syncService.providerType != .server {
syncService.providerType = .server syncService.providerType = .server
} }
dismiss() dismiss()
} }
.fontWeight(.semibold) .fontWeight(.semibold)
@@ -1116,7 +1111,7 @@ struct HealthKitSection: View {
struct MusicSection: View { struct MusicSection: View {
@EnvironmentObject var musicService: MusicService @EnvironmentObject var musicService: MusicService
var body: some View { var body: some View {
Section { Section {
Toggle(isOn: Binding( Toggle(isOn: Binding(
@@ -1129,7 +1124,7 @@ struct MusicSection: View {
Text("Apple Music Integration") Text("Apple Music Integration")
} }
} }
if musicService.isMusicEnabled { if musicService.isMusicEnabled {
if !musicService.isAuthorized { if !musicService.isAuthorized {
Button("Connect Apple Music") { Button("Connect Apple Music") {
@@ -1140,14 +1135,14 @@ struct MusicSection: View {
} else { } else {
Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled) Toggle("Auto-Play on Session Start", isOn: $musicService.isAutoPlayEnabled)
Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled) Toggle("Stop Music on Session End", isOn: $musicService.isAutoStopEnabled)
Picker("Playlist", selection: $musicService.selectedPlaylistId) { Picker("Playlist", selection: $musicService.selectedPlaylistId) {
Text("None").tag(nil as String?) Text("None").tag(nil as String?)
ForEach(musicService.playlists, id: \.id) { playlist in ForEach(musicService.playlists, id: \.id) { playlist in
Text(playlist.name).tag(playlist.id.rawValue as String?) Text(playlist.name).tag(playlist.id.rawValue as String?)
} }
} }
if musicService.isAutoPlayEnabled { if musicService.isAutoPlayEnabled {
Text("Music will only auto-play if headphones are connected when you start a session.") Text("Music will only auto-play if headphones are connected when you start a session.")
.font(.caption) .font(.caption)