diff --git a/Walkable.xcodeproj/project.pbxproj b/Walkable.xcodeproj/project.pbxproj index 476abbd..63a5bc2 100644 --- a/Walkable.xcodeproj/project.pbxproj +++ b/Walkable.xcodeproj/project.pbxproj @@ -156,7 +156,7 @@ 4A200AD9B414882AC6C7C572 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 51D80C129C05029BAD7DE958 /* WalkableApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = WalkableApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5515782EF765667BB5836D12 /* WalkableWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = WalkableWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 57B9A25E290E6711909B627B /* WalkableKit */ = {isa = PBXFileReference; lastKnownFileType = folder; path = WalkableKit; sourceTree = SOURCE_ROOT; }; + 57B9A25E290E6711909B627B /* WalkableKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WalkableKit; path = WalkableKit; sourceTree = SOURCE_ROOT; }; 57F061AAADC8839C2E49EE10 /* WalkTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkTabView.swift; sourceTree = ""; }; 59A20463D956D4332A76E146 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 5DB7618EACDEED770EA1628E /* WalkableLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkableLiveActivity.swift; sourceTree = ""; }; @@ -746,7 +746,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = WalkableWatch/WalkableWatch.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = HYM2LV4SPQ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Walkable; INFOPLIST_KEY_NSHealthShareUsageDescription = "Walkable reads health data during walks."; @@ -917,6 +916,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -929,6 +929,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -945,7 +946,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = WalkableWatch/WalkableWatch.entitlements; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = HYM2LV4SPQ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Walkable; INFOPLIST_KEY_NSHealthShareUsageDescription = "Walkable reads health data during walks."; @@ -1028,6 +1028,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -1046,6 +1047,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/WalkableApp/ContentView.swift b/WalkableApp/ContentView.swift index dd2db88..73950e6 100644 --- a/WalkableApp/ContentView.swift +++ b/WalkableApp/ContentView.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import HealthKit import WalkableKit struct ContentView: View { @@ -162,7 +163,8 @@ struct ContentView: View { guard !alreadyExists else { continue } // Find the closest matching route by distance - let workoutDistance = workout.totalDistance?.doubleValue(for: .meter()) ?? 0 + let distanceType = HKQuantityType(.distanceWalkingRunning) + let workoutDistance = workout.statistics(for: distanceType)?.sumQuantity()?.doubleValue(for: .meter()) ?? 0 guard let matchingRoute = allRoutes.min(by: { a, b in abs(a.distance - workoutDistance) < abs(b.distance - workoutDistance) }) else { continue } @@ -172,7 +174,8 @@ struct ContentView: View { session.completedAt = workout.endDate session.totalDistance = workoutDistance session.totalDuration = workout.duration - session.calories = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0 + let energyType = HKQuantityType(.activeEnergyBurned) + session.calories = workout.statistics(for: energyType)?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0 session.source = "healthkit" // Try to fetch the GPS route diff --git a/WalkableApp/Views/Library/LibraryView.swift b/WalkableApp/Views/Library/LibraryView.swift index b1e19ac..ba6c8cc 100644 --- a/WalkableApp/Views/Library/LibraryView.swift +++ b/WalkableApp/Views/Library/LibraryView.swift @@ -2,6 +2,7 @@ import SwiftUI import SwiftData import UniformTypeIdentifiers import CoreLocation +import WatchConnectivity import WalkableKit struct LibraryView: View { @@ -10,6 +11,8 @@ struct LibraryView: View { @State private var viewModel = LibraryViewModel() @ObservedObject private var locationService = LocationService.shared @State private var showImportPicker = false + @State private var showSyncAlert = false + @State private var syncStatus = "" @State private var importError: String? @State private var showImportError = false @@ -101,6 +104,24 @@ struct LibraryView: View { Image(systemName: "square.and.arrow.down") } } + ToolbarItem(placement: .primaryAction) { + Button { + let session = WCSession.default + let status = """ + Paired: \(session.isPaired) + Watch App Installed: \(session.isWatchAppInstalled) + Reachable: \(session.isReachable) + Activation: \(session.activationState.rawValue) + Routes: \(routes.count) + """ + syncStatus = status + SyncService.shared.syncAllRoutes(routes) + showSyncAlert = true + Haptics.success() + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + } } .fileImporter( isPresented: $showImportPicker, @@ -113,6 +134,11 @@ struct LibraryView: View { } message: { Text(importError ?? "Could not read GPX file.") } + .alert("Sync Status", isPresented: $showSyncAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(syncStatus) + } .sheet(item: $viewModel.selectedRoute) { route in RouteDetailSheet(route: route) { onStartWalk?(route) diff --git a/WalkableKit/Sources/WalkableKit/Services/HealthService.swift b/WalkableKit/Sources/WalkableKit/Services/HealthService.swift index a4924f3..6b008d1 100644 --- a/WalkableKit/Sources/WalkableKit/Services/HealthService.swift +++ b/WalkableKit/Sources/WalkableKit/Services/HealthService.swift @@ -189,12 +189,12 @@ public final class HealthService: NSObject, ObservableObject { guard let route = routes.first else { return [] } + let locationsBox = LocationsBox() return try await withCheckedThrowingContinuation { continuation in - var locations = [CLLocation]() let routeQuery = HKWorkoutRouteQuery(route: route) { _, batch, done, error in if let error { continuation.resume(throwing: error); return } - if let batch { locations.append(contentsOf: batch) } - if done { continuation.resume(returning: locations) } + if let batch { locationsBox.append(batch) } + if done { continuation.resume(returning: locationsBox.all) } } store.execute(routeQuery) } @@ -301,13 +301,13 @@ public final class HealthService: NSObject, ObservableObject { } #if os(watchOS) -extension HealthService: @preconcurrency HKWorkoutSessionDelegate { +extension HealthService: HKWorkoutSessionDelegate { public nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {} public nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {} } -extension HealthService: @preconcurrency HKLiveWorkoutBuilderDelegate { +extension HealthService: HKLiveWorkoutBuilderDelegate { public nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} public nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set) { @@ -337,3 +337,19 @@ extension HealthService: @preconcurrency HKLiveWorkoutBuilderDelegate { } } #endif + +/// Thread-safe accumulator for concurrent HKWorkoutRouteQuery callbacks. +private final class LocationsBox: @unchecked Sendable { + private let lock = NSLock() + private var storage = [CLLocation]() + + func append(_ batch: [CLLocation]) { + lock.lock(); defer { lock.unlock() } + storage.append(contentsOf: batch) + } + + var all: [CLLocation] { + lock.lock(); defer { lock.unlock() } + return storage + } +} diff --git a/WalkableWatch/ViewModels/WatchWalkViewModel.swift b/WalkableWatch/ViewModels/WatchWalkViewModel.swift index f58a9f9..cda1844 100644 --- a/WalkableWatch/ViewModels/WatchWalkViewModel.swift +++ b/WalkableWatch/ViewModels/WatchWalkViewModel.swift @@ -104,7 +104,7 @@ final class WatchWalkViewModel { .sink { [weak self] location in guard let self else { return } self.currentLocation = location.coordinate - self.distanceWalked = self.healthService.distanceWalked ?? 0 + self.distanceWalked = self.healthService.distanceWalked // Auto-re-center map if user hasn't swiped in 10 seconds if self.hasZoomedIn && Date().timeIntervalSince(self.lastManualMapInteraction) > 10 { diff --git a/project.yml b/project.yml index 5bcf98c..3eb7e3b 100644 --- a/project.yml +++ b/project.yml @@ -7,6 +7,11 @@ options: xcodeVersion: "16.0" createIntermediateGroups: true +settings: + base: + CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.0" + packages: WalkableKit: path: WalkableKit