Improve concurrency model for iOS
This commit is contained in:
@@ -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)",
|
||||||
|
|||||||
Binary file not shown.
@@ -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 {
|
||||||
|
|||||||
@@ -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!"))
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user