diff --git a/src/App/AppDelegate.swift b/src/App/AppDelegate.swift index 65a334d52..12e088424 100644 --- a/src/App/AppDelegate.swift +++ b/src/App/AppDelegate.swift @@ -104,6 +104,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // access the current OSM server to force capabilities download _ = OSM_SERVER + + WatchGpxSession.shared.activate() return true } diff --git a/src/App/MainViewController.swift b/src/App/MainViewController.swift index 55d20ca65..f8c1ef3ed 100644 --- a/src/App/MainViewController.swift +++ b/src/App/MainViewController.swift @@ -1378,6 +1378,7 @@ final class MainViewController: UIViewController, DPadDelegate, } } updateGpsButtonState() + WatchGpxSession.shared.update(force: true) } } diff --git a/src/App/WatchGpxSession.swift b/src/App/WatchGpxSession.swift new file mode 100644 index 000000000..08d3b3ea0 --- /dev/null +++ b/src/App/WatchGpxSession.swift @@ -0,0 +1,171 @@ +// +// WatchGpxSession.swift +// Go Map!! +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import CoreLocation +import UIKit +import WatchConnectivity + +final class WatchGpxSession: NSObject, WCSessionDelegate { + static let shared = WatchGpxSession() + + private static let maxOutlinePoints = 200 + private static let maxHistoryOutlinePoints = 40 + private static let maxHistoryCount = 20 + private static let minUpdateInterval = 5.0 + private static let minComplicationInterval = 15.0 * 60 + + private let locationManager = CLLocationManager() + private var pausedState: GpxWatchState? + private var lastUpdate = Date.distantPast + private var lastComplicationUpdate = Date.distantPast + private var lastComplicationStatus: GpxWatchState.Status? + + func activate() { + guard WCSession.isSupported() else { return } + WCSession.default.delegate = self + WCSession.default.activate() + } + + func update(force: Bool = false) { + guard WCSession.isSupported(), + WCSession.default.activationState == .activated, + force || Date().timeIntervalSince(lastUpdate) >= Self.minUpdateInterval + else { + return + } + lastUpdate = Date() + let session = WCSession.default + var state = currentState() + try? session.updateApplicationContext(state.encoded()) + + if session.isComplicationEnabled, + state.status != lastComplicationStatus + || Date().timeIntervalSince(lastComplicationUpdate) >= Self.minComplicationInterval + { + state.outline = [] + session.transferCurrentComplicationUserInfo(state.encoded()) + lastComplicationStatus = state.status + lastComplicationUpdate = Date() + } + } + + private func currentState() -> GpxWatchState { + let tracks = AppState.shared.gpxTracks + var state = pausedState ?? GpxWatchState() + if let track = tracks.activeTrack { + state = GpxWatchState() + state.status = .running + state.startTime = tracks.activeTrackStartDate + state.pointCount = tracks.activeTrackTotalPointCount + state.outline = Self.outline(of: track, maxCount: Self.maxOutlinePoints) + } + state.recordsInBackground = tracks.recordTracksInBackground + && (AppDelegate.shared.mainView?.settings.displayGpxTracks ?? false) + state.hasAlwaysLocationAccess = locationManager.authorizationStatus == .authorizedAlways + return state + } + + private static func outline(of track: GpxTrack, maxCount: Int) -> [[Double]] { + return track.points.map { [$0.latLon.lat, $0.latLon.lon] }.simplified(to: maxCount) + } + + private func history() -> [GpxTraceSummary] { + return AppState.shared.gpxTracks.savedTracks.prefix(Self.maxHistoryCount).map { + GpxTraceSummary(id: $0.fileBaseName(), + startTime: $0.creationDate, + duration: $0.duration(), + pointCount: $0.points.count, + outline: Self.outline(of: $0, maxCount: Self.maxHistoryOutlinePoints)) + } + } + + private func perform(_ command: GpxWatchState.Command, message: [String: Any]) -> [String: Any] { + guard let mainView = AppDelegate.shared.mainView else { return [:] } + let tracks = AppState.shared.gpxTracks + switch command { + case .refresh: + return [:] + case .history: + return GpxTraceSummary.encoded(history()) + case .delete: + if let id = message[GpxWatchState.traceIdKey] as? String, + let track = tracks.savedTracks.first(where: { $0.fileBaseName() == id }) + { + tracks.delete(track: track) + } + return GpxTraceSummary.encoded(history()) + case .setBackgroundRecording: + let enabled = message[GpxWatchState.enabledKey] as? Bool ?? true + if enabled { + mainView.settings.displayGpxTracks = true + } + tracks.recordTracksInBackground = enabled + case .start, .pause, .stop: + setRecording(command, mainView: mainView) + } + update(force: true) + return [:] + } + + private func setRecording(_ command: GpxWatchState.Command, mainView: MainViewController) { + pausedState = nil + if command == .pause, AppState.shared.gpxTracks.activeTrack != nil { + var state = currentState() + state.status = .paused + state.endTime = Date() + pausedState = state + } + +#if canImport(ActivityKit) && !targetEnvironment(macCatalyst) + if UIApplication.shared.applicationState == .background { + switch command { + case .start: widgetStartGPS() + case .pause: widgetPauseGPS() + default: widgetStopGPS() + } + } +#endif + if command == .start { + if mainView.gpsState == .NONE { + mainView.gpsState = .LOCATION + } + } else if mainView.gpsState != .NONE { + mainView.gpsState = .NONE + } + } + + func session(_ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error?) + { + DispatchQueue.main.async { + self.update(force: true) + } + } + + func session(_ session: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + DispatchQueue.main.async { + var reply: [String: Any] = [:] + if let name = message[GpxWatchState.commandKey] as? String, + let command = GpxWatchState.Command(rawValue: name) + { + reply = self.perform(command, message: message) + } + reply.merge(self.currentState().encoded()) { current, _ in current } + replyHandler(reply) + } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } +} diff --git a/src/GoMap.xcodeproj/project.pbxproj b/src/GoMap.xcodeproj/project.pbxproj index ae5092831..868f1e67e 100644 --- a/src/GoMap.xcodeproj/project.pbxproj +++ b/src/GoMap.xcodeproj/project.pbxproj @@ -306,6 +306,21 @@ EDB2C3A12609E2A000C08293 /* LanguageTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB2C3A02609E29F00C08293 /* LanguageTableViewController.swift */; }; EDB2C3A62609E6E100C08293 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDB2C3A52609E6E100C08293 /* LoginViewController.swift */; }; EDDBA55326130287001E7D5C /* GpxViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED56E5EA26028B5F000B368C /* GpxViewController.swift */; }; + A7C000000000000000000110 /* WatchGpxSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000010F /* WatchGpxSession.swift */; }; + A7C000000000000000000112 /* GpxWatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000111 /* GpxWatchState.swift */; }; + A7C000000000000000000113 /* GpxWatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000111 /* GpxWatchState.swift */; }; + A7C000000000000000000114 /* GpxWatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000111 /* GpxWatchState.swift */; }; + A7C000000000000000000116 /* GoMapWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000115 /* GoMapWatchApp.swift */; }; + A7C000000000000000000118 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000117 /* ContentView.swift */; }; + A7C00000000000000000011A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000119 /* HistoryView.swift */; }; + A7C00000000000000000011C /* WatchGpxModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000011B /* WatchGpxModel.swift */; }; + A7C00000000000000000011E /* TraceRuntimeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000011D /* TraceRuntimeSession.swift */; }; + A7C000000000000000000120 /* WatchShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000011F /* WatchShared.swift */; }; + A7C000000000000000000121 /* WatchShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000011F /* WatchShared.swift */; }; + A7C000000000000000000125 /* GpxComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7C000000000000000000124 /* GpxComplication.swift */; }; + A7C000000000000000000128 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 02C3D889306FE1EB00FB815C /* AppIcon.icon */; }; + A7C000000000000000000129 /* GoMapWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000010E /* GoMapWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + A7C00000000000000000012A /* GoMapWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = A7C00000000000000000010D /* GoMapWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXBuildRule section */ @@ -369,6 +384,20 @@ remoteGlobalIDString = 02ACBA2E16713CCB00BB4414; remoteInfo = "Go Map!!"; }; + A7C00000000000000000012B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 02ACBA2616713CCB00BB4414 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A7C000000000000000000016; + remoteInfo = GoMapWatch; + }; + A7C00000000000000000012C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 02ACBA2616713CCB00BB4414 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A7C00000000000000000010C; + remoteInfo = GoMapWatchWidget; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -384,6 +413,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + A7C000000000000000000107 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A7C000000000000000000129 /* GoMapWatchWidget.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + A7C00000000000000000010B /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + A7C00000000000000000012A /* GoMapWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -711,6 +762,21 @@ ED964A002601D6A900EC0A60 /* POICommonTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POICommonTagsViewController.swift; sourceTree = ""; }; EDB2C3A02609E29F00C08293 /* LanguageTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LanguageTableViewController.swift; sourceTree = ""; }; EDB2C3A52609E6E100C08293 /* LoginViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; + A7C00000000000000000010F /* WatchGpxSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchGpxSession.swift; sourceTree = ""; }; + A7C000000000000000000111 /* GpxWatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GpxWatchState.swift; sourceTree = ""; }; + A7C000000000000000000115 /* GoMapWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoMapWatchApp.swift; sourceTree = ""; }; + A7C000000000000000000117 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A7C000000000000000000119 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + A7C00000000000000000011B /* WatchGpxModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchGpxModel.swift; sourceTree = ""; }; + A7C00000000000000000011D /* TraceRuntimeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraceRuntimeSession.swift; sourceTree = ""; }; + A7C00000000000000000011F /* WatchShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchShared.swift; sourceTree = ""; }; + A7C000000000000000000122 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A7C000000000000000000123 /* GoMapWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GoMapWatch.entitlements; sourceTree = ""; }; + A7C000000000000000000124 /* GpxComplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GpxComplication.swift; sourceTree = ""; }; + A7C000000000000000000126 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A7C000000000000000000127 /* GoMapWatchWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GoMapWatchWidget.entitlements; sourceTree = ""; }; + A7C00000000000000000010D /* GoMapWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GoMapWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A7C00000000000000000010E /* GoMapWatchWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GoMapWatchWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -764,6 +830,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A7C000000000000000000105 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A7C000000000000000000109 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -834,6 +914,7 @@ children = ( 02225DA629EB3AE900859F9D /* GpxTrack.swift */, 0210AE062F19A9DF0031F047 /* GpxTracks.swift */, + A7C000000000000000000111 /* GpxWatchState.swift */, ); name = GpxTracks; path = Shared/GpxTracks; @@ -902,6 +983,8 @@ 02CBF3B62BE8529A000DE2AD /* GpxWidget */, 02ACBA3216713CCB00BB4414 /* Frameworks */, 02ACBA3016713CCB00BB4414 /* Products */, + A7C000000000000000000101 /* GoMapWatch */, + A7C000000000000000000102 /* GoMapWatchWidget */, ); sourceTree = ""; }; @@ -913,6 +996,8 @@ 64348D08225EA24E00ADE7FB /* GoMapUITests.xctest */, 02D6237F26A6628300A4F147 /* PhotoShare.appex */, 02CBF3B12BE8529A000DE2AD /* GpxWidgetExtension.appex */, + A7C00000000000000000010D /* GoMapWatch.app */, + A7C00000000000000000010E /* GoMapWatchWidget.appex */, ); name = Products; sourceTree = ""; @@ -993,6 +1078,7 @@ 02E9ABC32BA54C9500F0EF69 /* PrivacyInfo.xcprivacy */, 020E0E242F637CB200A9D27C /* RotationGestureRecognizer.swift */, 02811D6B2E3176BE00E9D889 /* SceneDelegate.swift */, + A7C00000000000000000010F /* WatchGpxSession.swift */, ); path = App; sourceTree = ""; @@ -1392,6 +1478,31 @@ path = iD; sourceTree = ""; }; + A7C000000000000000000101 /* GoMapWatch */ = { + isa = PBXGroup; + children = ( + A7C000000000000000000115 /* GoMapWatchApp.swift */, + A7C000000000000000000117 /* ContentView.swift */, + A7C000000000000000000119 /* HistoryView.swift */, + A7C00000000000000000011B /* WatchGpxModel.swift */, + A7C00000000000000000011D /* TraceRuntimeSession.swift */, + A7C00000000000000000011F /* WatchShared.swift */, + A7C000000000000000000122 /* Info.plist */, + A7C000000000000000000123 /* GoMapWatch.entitlements */, + ); + path = GoMapWatch; + sourceTree = ""; + }; + A7C000000000000000000102 /* GoMapWatchWidget */ = { + isa = PBXGroup; + children = ( + A7C000000000000000000124 /* GpxComplication.swift */, + A7C000000000000000000126 /* Info.plist */, + A7C000000000000000000127 /* GoMapWatchWidget.entitlements */, + ); + path = GoMapWatchWidget; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1403,6 +1514,7 @@ 02ACBA2C16713CCB00BB4414 /* Frameworks */, 02ACBA2D16713CCB00BB4414 /* Resources */, 02D6238E26A6628300A4F147 /* Embed Foundation Extensions */, + A7C00000000000000000010B /* Embed Watch Content */, ); buildRules = ( 022B2FD92718C9FC00AB4C28 /* PBXBuildRule */, @@ -1410,6 +1522,7 @@ dependencies = ( 02D6238826A6628300A4F147 /* PBXTargetDependency */, 02CBF3C12BE8529B000DE2AD /* PBXTargetDependency */, + A7C00000000000000000012D /* PBXTargetDependency */, ); name = "Go Map!!"; packageProductDependencies = ( @@ -1491,6 +1604,42 @@ productReference = 64348D08225EA24E00ADE7FB /* GoMapUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + A7C000000000000000000016 /* GoMapWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = A7C000000000000000000133 /* Build configuration list for PBXNativeTarget "GoMapWatch" */; + buildPhases = ( + A7C000000000000000000104 /* Sources */, + A7C000000000000000000105 /* Frameworks */, + A7C000000000000000000106 /* Resources */, + A7C000000000000000000107 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + A7C00000000000000000012E /* PBXTargetDependency */, + ); + name = GoMapWatch; + productName = GoMapWatch; + productReference = A7C00000000000000000010D /* GoMapWatch.app */; + productType = "com.apple.product-type.application"; + }; + A7C00000000000000000010C /* GoMapWatchWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = A7C000000000000000000134 /* Build configuration list for PBXNativeTarget "GoMapWatchWidget" */; + buildPhases = ( + A7C000000000000000000108 /* Sources */, + A7C000000000000000000109 /* Frameworks */, + A7C00000000000000000010A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GoMapWatchWidget; + productName = GoMapWatchWidget; + productReference = A7C00000000000000000010E /* GoMapWatchWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1578,6 +1727,8 @@ 64348D07225EA24E00ADE7FB /* GoMapUITests */, 02D6237E26A6628300A4F147 /* PhotoShare */, 02CBF3B02BE8529A000DE2AD /* GpxWidgetExtension */, + A7C000000000000000000016 /* GoMapWatch */, + A7C00000000000000000010C /* GoMapWatchWidget */, ); }; /* End PBXProject section */ @@ -1653,6 +1804,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A7C000000000000000000106 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A7C000000000000000000128 /* AppIcon.icon in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A7C00000000000000000010A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1883,6 +2049,8 @@ ED9649F12601CEC800EC0A60 /* POITabBarController.swift in Sources */, 022B2FDB2718CCAC00AB4C28 /* MapboxLocator.secret in Sources */, 02148A562FFA1433002D513D /* PanoramaxServer.swift in Sources */, + A7C000000000000000000110 /* WatchGpxSession.swift in Sources */, + A7C000000000000000000112 /* GpxWatchState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1938,6 +2106,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A7C000000000000000000104 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A7C000000000000000000113 /* GpxWatchState.swift in Sources */, + A7C000000000000000000116 /* GoMapWatchApp.swift in Sources */, + A7C000000000000000000118 /* ContentView.swift in Sources */, + A7C00000000000000000011A /* HistoryView.swift in Sources */, + A7C00000000000000000011C /* WatchGpxModel.swift in Sources */, + A7C00000000000000000011E /* TraceRuntimeSession.swift in Sources */, + A7C000000000000000000120 /* WatchShared.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A7C000000000000000000108 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A7C000000000000000000114 /* GpxWatchState.swift in Sources */, + A7C000000000000000000121 /* WatchShared.swift in Sources */, + A7C000000000000000000125 /* GpxComplication.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1962,6 +2154,17 @@ target = 02ACBA2E16713CCB00BB4414 /* Go Map!! */; targetProxy = 64348D0D225EA24E00ADE7FB /* PBXContainerItemProxy */; }; + A7C00000000000000000012D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = A7C000000000000000000016 /* GoMapWatch */; + targetProxy = A7C00000000000000000012B /* PBXContainerItemProxy */; + }; + A7C00000000000000000012E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A7C00000000000000000010C /* GoMapWatchWidget */; + targetProxy = A7C00000000000000000012C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2715,6 +2918,140 @@ }; name = Release; }; + A7C00000000000000000012F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = GoMapWatch/GoMapWatch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6201; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = MKTTC6734C; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GoMapWatch/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Go Map!!"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Bryce Cogswell. All rights reserved."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.bryceco.GoMap; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 6.2.1; + PRODUCT_BUNDLE_IDENTIFIER = com.bryceco.GoMap.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + A7C000000000000000000130 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = GoMapWatch/GoMapWatch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6201; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = MKTTC6734C; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GoMapWatch/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Go Map!!"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Bryce Cogswell. All rights reserved."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.bryceco.GoMap; + INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 6.2.1; + PRODUCT_BUNDLE_IDENTIFIER = com.bryceco.GoMap.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + A7C000000000000000000131 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = GoMapWatchWidget/GoMapWatchWidget.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6201; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = MKTTC6734C; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GoMapWatchWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Go Map!!"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Bryce Cogswell. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 6.2.1; + PRODUCT_BUNDLE_IDENTIFIER = com.bryceco.GoMap.watchkitapp.GpxComplication; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + A7C000000000000000000132 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = GoMapWatchWidget/GoMapWatchWidget.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6201; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = MKTTC6734C; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GoMapWatchWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Go Map!!"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Bryce Cogswell. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 6.2.1; + PRODUCT_BUNDLE_IDENTIFIER = com.bryceco.GoMap.watchkitapp.GpxComplication; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2772,6 +3109,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A7C000000000000000000133 /* Build configuration list for PBXNativeTarget "GoMapWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A7C00000000000000000012F /* Debug */, + A7C000000000000000000130 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A7C000000000000000000134 /* Build configuration list for PBXNativeTarget "GoMapWatchWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A7C000000000000000000131 /* Debug */, + A7C000000000000000000132 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/src/GoMap.xcodeproj/xcshareddata/xcschemes/GoMapWatch.xcscheme b/src/GoMap.xcodeproj/xcshareddata/xcschemes/GoMapWatch.xcscheme new file mode 100644 index 000000000..e5bdce87f --- /dev/null +++ b/src/GoMap.xcodeproj/xcshareddata/xcschemes/GoMapWatch.xcscheme @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GoMapWatch/ContentView.swift b/src/GoMapWatch/ContentView.swift new file mode 100644 index 000000000..b24b593a7 --- /dev/null +++ b/src/GoMapWatch/ContentView.swift @@ -0,0 +1,290 @@ +// +// ContentView.swift +// GoMapWatch +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import SwiftUI + +private let screenBackground = LinearGradient(colors: [.black, Color.goMapSlate.opacity(0.9)], + startPoint: .top, + endPoint: .bottom) + +struct ContentView: View { + @EnvironmentObject private var model: WatchGpxModel + @Environment(\.scenePhase) private var scenePhase + @State private var page = Page.trace + + private enum Page { + case controls + case trace + } + + var body: some View { + NavigationStack { + Group { + if model.state.status == .idle { + StartView() + } else { + TabView(selection: $page) { + ControlsView().tag(Page.controls) + TraceView().tag(Page.trace) + } + .tabViewStyle(.page) + } + } + .containerBackground(screenBackground, for: .navigation) + } + .onChange(of: model.state.status) { _, status in + if status == .running { + page = .trace + } + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + model.keepOnScreenIfRecording() + } + } + .alert(model.errorMessage ?? "", + isPresented: Binding(get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } })) + { + Button("OK", role: .cancel) {} + } + } + +} + +struct StartView: View { + @EnvironmentObject private var model: WatchGpxModel + @State private var askToRecordInBackground = false + + var body: some View { + VStack(spacing: 6) { + Image(systemName: gpxTraceSymbol) + .font(.system(size: 36, weight: .semibold)) + .foregroundStyle(Color.goMapGreen) + Text("GPX Trace") + .font(.title3.weight(.semibold)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 16) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + NavigationLink { + HistoryView() + } label: { + Image(systemName: "clock.arrow.circlepath") + } + .accessibilityLabel("History") + } + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + SettingsView() + } label: { + Image(systemName: needsAttention ? "exclamationmark.triangle.fill" : "gearshape") + .foregroundStyle(needsAttention ? Color.goMapYellow : .primary) + } + .accessibilityLabel("Settings") + } + ToolbarItemGroup(placement: .bottomBar) { + Spacer() + Button { + if model.state.recordsInBackground { + model.send(.start) + } else { + askToRecordInBackground = true + } + } label: { + Image(systemName: "play.fill") + } + .controlSize(.large) + .disabled(model.isSending) + .accessibilityLabel("Start GPX Trace") + Spacer() + } + } + .alert("Keep recording when your iPhone is locked?", isPresented: $askToRecordInBackground) { + Button("Turn On and Start") { + model.setBackgroundRecording(true) + model.send(.start) + } + Button("Start Anyway") { + model.send(.start) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Record in Background is off in Go Map!! on your iPhone, so recording stops when Go Map!! isn't open.") + } + } + + private var needsAttention: Bool { + !model.state.recordsInBackground || !model.state.hasAlwaysLocationAccess + } +} + +struct SettingsView: View { + @EnvironmentObject private var model: WatchGpxModel + + var body: some View { + List { + Section { + Toggle("Record in Background", + isOn: Binding(get: { model.state.recordsInBackground }, + set: { model.setBackgroundRecording($0) })) + .tint(.goMapGreen) + } footer: { + Text("Keeps recording when your iPhone is locked or Go Map!! isn't open. Also shows GPX traces on the iPhone's map.") + } + if model.state.recordsInBackground, !model.state.hasAlwaysLocationAccess { + Section { + Label("Location access", systemImage: "location.slash") + .foregroundStyle(Color.goMapYellow) + } footer: { + Text("On your iPhone, open Settings › Apps › Go Map!! › Location and choose Always.") + } + } + } + .navigationTitle("Settings") + .onAppear { + model.send(.refresh) + } + } +} + +struct TraceView: View { + @EnvironmentObject private var model: WatchGpxModel + + var body: some View { + let state = model.state + VStack(spacing: 6) { + TraceOutline(points: state.outline) + HStack(alignment: .bottom) { + metric("Points", alignment: .leading) { + Text("\(state.pointCount)") + } + Spacer(minLength: 8) + metric("Duration", alignment: .trailing) { + if let start = state.startTime { + HStack(spacing: 4) { + if state.status == .paused { + Image(systemName: "pause.fill") + .font(.system(size: 20, weight: .semibold)) + .accessibilityLabel("Paused") + } + Text(timerInterval: start...Date.distantFuture, pauseTime: state.endTime, countsDown: false) + } + .foregroundStyle(state.status == .paused ? Color.goMapYellow : .primary) + } + } + } + .padding(.horizontal, 10) + } + .padding(.bottom, 4) + } + + private func metric(_ caption: LocalizedStringKey, alignment: HorizontalAlignment, + @ViewBuilder value: () -> some View) -> some View + { + VStack(alignment: alignment, spacing: -2) { + value() + .font(.system(size: 30, weight: .semibold)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(caption) + .font(.footnote) + .foregroundStyle(.secondary) + } + } +} + +struct TraceOutline: View { + let points: [[Double]] + var lineWidth = 4.0 + var ringRadius = 5.0 + + var body: some View { + Canvas { context, size in + guard points.count > 1 else { return } + let lats = points.map { $0[0] } + let midLat = (lats.min()! + lats.max()!) / 2 + let xs = points.map { $0[1] * cos(midLat * .pi / 180) } + let (minX, maxX, minY, maxY) = (xs.min()!, xs.max()!, lats.min()!, lats.max()!) + let inset = ringRadius + lineWidth + let scale = min((size.width - 2 * inset) / max(maxX - minX, 1e-9), + (size.height - 2 * inset) / max(maxY - minY, 1e-9)) + func point(_ i: Int) -> CGPoint { + CGPoint(x: size.width / 2 + (xs[i] - (minX + maxX) / 2) * scale, + y: size.height / 2 - (lats[i] - (minY + maxY) / 2) * scale) + } + var path = Path() + path.move(to: point(0)) + for i in 1.. 0 else { return } + for end in [point(0), point(points.count - 1)] { + let r = ringRadius + let ring = Path(ellipseIn: CGRect(x: end.x - r, y: end.y - r, width: 2 * r, height: 2 * r)) + context.fill(ring, with: .color(.black)) + context.stroke(ring, with: .color(.gpxTrace), style: style) + } + } + .overlay { + if points.count < 2, ringRadius > 0 { + Text("Waiting for GPS…") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } +} + +struct ControlsView: View { + @EnvironmentObject private var model: WatchGpxModel + + var body: some View { + HStack(spacing: 10) { + ControlButton(title: "End", systemImage: "xmark", tint: .red) { + model.send(.stop) + } + if model.state.status == .paused { + ControlButton(title: "Resume", systemImage: "play.fill", tint: .goMapGreen) { + model.send(.start) + } + } else { + ControlButton(title: "Pause", systemImage: "pause.fill", tint: .goMapYellow) { + model.send(.pause) + } + } + } + .padding(.horizontal, 8) + .disabled(model.isSending) + } +} + +private struct ControlButton: View { + let title: LocalizedStringKey + let systemImage: String + let tint: Color + let action: () -> Void + + var body: some View { + VStack(spacing: 6) { + Button(action: action) { + Image(systemName: systemImage) + .font(.title2) + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle) + .tint(tint) + Text(title) + .font(.footnote) + } + } +} diff --git a/src/GoMapWatch/GoMapWatch.entitlements b/src/GoMapWatch/GoMapWatch.entitlements new file mode 100644 index 000000000..e079a5d2f --- /dev/null +++ b/src/GoMapWatch/GoMapWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.bryceco.GoMap + + + diff --git a/src/GoMapWatch/GoMapWatchApp.swift b/src/GoMapWatch/GoMapWatchApp.swift new file mode 100644 index 000000000..3e612280a --- /dev/null +++ b/src/GoMapWatch/GoMapWatchApp.swift @@ -0,0 +1,20 @@ +// +// GoMapWatchApp.swift +// GoMapWatch +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import SwiftUI + +@main +struct GoMapWatchApp: App { + @StateObject private var model = WatchGpxModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + } + } +} diff --git a/src/GoMapWatch/HistoryView.swift b/src/GoMapWatch/HistoryView.swift new file mode 100644 index 000000000..cfb126885 --- /dev/null +++ b/src/GoMapWatch/HistoryView.swift @@ -0,0 +1,68 @@ +// +// HistoryView.swift +// GoMapWatch +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import SwiftUI + +struct HistoryView: View { + @EnvironmentObject private var model: WatchGpxModel + + var body: some View { + Group { + if let traces = model.history { + if traces.isEmpty { + ContentUnavailableView("No Traces", + systemImage: gpxTraceSymbol, + description: Text("GPX traces you record appear here.")) + } else { + List { + ForEach(traces) { trace in + HistoryRow(trace: trace) + } + .onDelete { offsets in + for trace in offsets.map({ traces[$0] }) { + model.delete(trace) + } + } + } + } + } else { + ProgressView() + } + } + .navigationTitle("History") + .onAppear { + model.loadHistory() + } + } +} + +private struct HistoryRow: View { + let trace: GpxTraceSummary + + var body: some View { + HStack(spacing: 10) { + TraceOutline(points: trace.outline, lineWidth: 2, ringRadius: 0) + .frame(width: 34, height: 34) + VStack(alignment: .leading, spacing: 2) { + Text(trace.startTime, format: .dateTime.month(.abbreviated).day().hour().minute()) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(details) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + private var details: String { + let duration = Duration.seconds(trace.duration.rounded()) + .formatted(.time(pattern: trace.duration >= 3600 ? .hourMinuteSecond : .minuteSecond)) + return String(localized: "\(duration) · \(trace.pointCount) points") + } +} diff --git a/src/GoMapWatch/Info.plist b/src/GoMapWatch/Info.plist new file mode 100644 index 000000000..f1195ca9e --- /dev/null +++ b/src/GoMapWatch/Info.plist @@ -0,0 +1,10 @@ + + + + + WKBackgroundModes + + mindfulness + + + diff --git a/src/GoMapWatch/TraceRuntimeSession.swift b/src/GoMapWatch/TraceRuntimeSession.swift new file mode 100644 index 000000000..590b6b2f2 --- /dev/null +++ b/src/GoMapWatch/TraceRuntimeSession.swift @@ -0,0 +1,44 @@ +// +// TraceRuntimeSession.swift +// GoMapWatch +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import WatchKit + +final class TraceRuntimeSession: NSObject, WKExtendedRuntimeSessionDelegate { + private var session: WKExtendedRuntimeSession? + + func update(recording: Bool) { + if recording { + guard session == nil || session?.state == .invalid, + WKApplication.shared().applicationState == .active + else { + return + } + let session = WKExtendedRuntimeSession() + session.delegate = self + session.start() + self.session = session + } else { + if session?.state == .running { + session?.invalidate() + } + session = nil + } + } + + func extendedRuntimeSessionDidStart(_ extendedRuntimeSession: WKExtendedRuntimeSession) {} + + func extendedRuntimeSessionWillExpire(_ extendedRuntimeSession: WKExtendedRuntimeSession) {} + + func extendedRuntimeSession(_ extendedRuntimeSession: WKExtendedRuntimeSession, + didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason, + error: Error?) + { + if extendedRuntimeSession === session { + session = nil + } + } +} diff --git a/src/GoMapWatch/WatchGpxModel.swift b/src/GoMapWatch/WatchGpxModel.swift new file mode 100644 index 000000000..1c95c418b --- /dev/null +++ b/src/GoMapWatch/WatchGpxModel.swift @@ -0,0 +1,137 @@ +// +// WatchGpxModel.swift +// GoMapWatch +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import SwiftUI +import WatchConnectivity +import WidgetKit + +@MainActor +final class WatchGpxModel: NSObject, ObservableObject, WCSessionDelegate { + @Published private(set) var state = GpxComplicationStore.load() + @Published private(set) var history: [GpxTraceSummary]? + @Published private(set) var isSending = false + @Published var errorMessage: String? + + private static let minComplicationInterval = 60.0 + private var lastComplicationReload = Date.distantPast + private let runtimeSession = TraceRuntimeSession() + + override init() { + super.init() + if WCSession.isSupported() { + WCSession.default.delegate = self + WCSession.default.activate() + } + } + + func send(_ command: GpxWatchState.Command, _ parameters: [String: Any] = [:]) { + let session = WCSession.default + let quiet = command == .refresh || command == .history + guard session.activationState == .activated, session.isReachable else { + if !quiet { + errorMessage = String(localized: "Go Map!! needs your iPhone nearby to record GPX traces.") + } + return + } + isSending = !quiet + var message = parameters + message[GpxWatchState.commandKey] = command.rawValue + session.sendMessage(message, + replyHandler: { reply in + let state = GpxWatchState(reply) + let history = GpxTraceSummary.decoded(reply) + Task { @MainActor in + self.isSending = false + if let state { + self.apply(state) + } + if let history { + self.history = history + } + } + }, + errorHandler: { error in + Task { @MainActor in + self.isSending = false + if !quiet { + self.errorMessage = error.localizedDescription + } + } + }) + } + + func loadHistory() { + send(.history) + } + + func delete(_ trace: GpxTraceSummary) { + history?.removeAll { $0.id == trace.id } + send(.delete, [GpxWatchState.traceIdKey: trace.id]) + } + + func setBackgroundRecording(_ enabled: Bool) { + send(.setBackgroundRecording, [GpxWatchState.enabledKey: enabled]) + } + + func keepOnScreenIfRecording() { + runtimeSession.update(recording: state.status != .idle) + } + + private func apply(_ newState: GpxWatchState) { + let oldState = state + state = newState + keepOnScreenIfRecording() + GpxComplicationStore.save(newState) + if newState.status != oldState.status + || newState.startTime != oldState.startTime + || Date().timeIntervalSince(lastComplicationReload) >= Self.minComplicationInterval + { + lastComplicationReload = Date() + WidgetCenter.shared.reloadTimelines(ofKind: GpxComplicationStore.widgetKind) + } + } + + nonisolated func session(_ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error?) + { + let state = GpxWatchState(session.receivedApplicationContext) + Task { @MainActor in + if let state { + self.apply(state) + } + self.send(.refresh) + } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + let state = GpxWatchState(applicationContext) + Task { @MainActor in + if let state { + self.apply(state) + } + } + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + let state = GpxWatchState(userInfo) + Task { @MainActor in + if let state { + self.apply(state) + } + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + if reachable { + self.send(.refresh) + } + } + } +} diff --git a/src/GoMapWatch/WatchShared.swift b/src/GoMapWatch/WatchShared.swift new file mode 100644 index 000000000..3efaf7401 --- /dev/null +++ b/src/GoMapWatch/WatchShared.swift @@ -0,0 +1,39 @@ +// +// WatchShared.swift +// GoMapWatch +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import SwiftUI + +let gpxTraceSymbol = "point.bottomleft.forward.to.point.topright.scurvepath" + +extension Color { + static let goMapGreen = Color(red: 0x4C / 255.0, green: 0xD9 / 255.0, blue: 0x64 / 255.0) + static let goMapYellow = Color(red: 0xFF / 255.0, green: 0xCC / 255.0, blue: 0x00 / 255.0) + static let goMapSlate = Color(red: 0x34 / 255.0, green: 0x49 / 255.0, blue: 0x5E / 255.0) + static let gpxTrace = goMapGreen +} + +enum GpxComplicationStore { + static let appGroup = "group.com.bryceco.GoMap" + static let widgetKind = "GpxComplication" + private static let key = "gpxState" + + static func load() -> GpxWatchState { + guard let data = UserDefaults(suiteName: appGroup)?.data(forKey: key), + let state = try? JSONDecoder().decode(GpxWatchState.self, from: data) + else { + return GpxWatchState() + } + return state + } + + static func save(_ state: GpxWatchState) { + var state = state + state.outline = [] + guard let data = try? JSONEncoder().encode(state) else { return } + UserDefaults(suiteName: appGroup)?.set(data, forKey: key) + } +} diff --git a/src/GoMapWatchWidget/GoMapWatchWidget.entitlements b/src/GoMapWatchWidget/GoMapWatchWidget.entitlements new file mode 100644 index 000000000..e079a5d2f --- /dev/null +++ b/src/GoMapWatchWidget/GoMapWatchWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.bryceco.GoMap + + + diff --git a/src/GoMapWatchWidget/GpxComplication.swift b/src/GoMapWatchWidget/GpxComplication.swift new file mode 100644 index 000000000..f67d88839 --- /dev/null +++ b/src/GoMapWatchWidget/GpxComplication.swift @@ -0,0 +1,127 @@ +// +// GpxComplication.swift +// GoMapWatchWidget +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import SwiftUI +import WidgetKit + +struct GpxComplicationProvider: TimelineProvider { + struct Entry: TimelineEntry { + let date: Date + let state: GpxWatchState + } + + func placeholder(in context: Context) -> Entry { + Entry(date: .now, state: GpxWatchState()) + } + + func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) { + completion(Entry(date: .now, state: GpxComplicationStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [Entry(date: .now, state: GpxComplicationStore.load())], policy: .never)) + } +} + +struct GpxComplicationView: View { + let state: GpxWatchState + @Environment(\.widgetFamily) private var family + + var body: some View { + if let start = state.startTime, state.status != .idle { + recording(start: start) + } else { + launcher + } + } + + @ViewBuilder private var launcher: some View { + switch family { + case .accessoryCorner: + Image(systemName: gpxTraceSymbol) + .font(.title2) + .widgetAccentable() + .widgetLabel("GPX") + case .accessoryRectangular: + VStack(alignment: .leading) { + Label("Go Map!!", systemImage: gpxTraceSymbol) + .font(.headline) + .widgetAccentable() + Text("Start GPX trace") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + case .accessoryInline: + Label("Start GPX", systemImage: gpxTraceSymbol) + default: + ZStack { + AccessoryWidgetBackground() + Image(systemName: gpxTraceSymbol) + .font(.title3) + .widgetAccentable() + } + } + } + + @ViewBuilder private func recording(start: Date) -> some View { + let duration = Text(timerInterval: start...Date.distantFuture, pauseTime: state.endTime, countsDown: false) + let paused = state.status == .paused + switch family { + case .accessoryCorner: + Image(systemName: paused ? "pause.fill" : gpxTraceSymbol) + .font(.title3) + .widgetAccentable() + .widgetLabel { + duration + } + case .accessoryRectangular: + VStack(alignment: .leading, spacing: 0) { + Label(paused ? "GPX Paused" : "GPX Trace", systemImage: paused ? "pause.fill" : "record.circle") + .font(.headline) + .foregroundStyle(paused ? Color.goMapYellow : Color.gpxTrace) + .widgetAccentable() + duration + .font(.title2.weight(.semibold)) + .monospacedDigit() + Text("\(state.pointCount) points") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + case .accessoryInline: + Text("\(Image(systemName: gpxTraceSymbol)) ") + duration + Text(" · \(state.pointCount) pts") + default: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Image(systemName: paused ? "pause.fill" : gpxTraceSymbol) + .font(.caption) + .foregroundStyle(paused ? Color.goMapYellow : Color.gpxTrace) + .widgetAccentable() + duration + .font(.caption.weight(.semibold)) + .monospacedDigit() + .multilineTextAlignment(.center) + .minimumScaleFactor(0.6) + } + .padding(4) + } + } + } +} + +@main +struct GpxComplication: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: GpxComplicationStore.widgetKind, provider: GpxComplicationProvider()) { entry in + GpxComplicationView(state: entry.state) + .containerBackground(for: .widget) {} + } + .configurationDisplayName("GPX Trace") + .description("Start a Go Map!! GPX trace, or see the one being recorded.") + .supportedFamilies([.accessoryCircular, .accessoryCorner, .accessoryRectangular, .accessoryInline]) + } +} diff --git a/src/GoMapWatchWidget/Info.plist b/src/GoMapWatchWidget/Info.plist new file mode 100644 index 000000000..0f118fb75 --- /dev/null +++ b/src/GoMapWatchWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/src/Shared/GpxTracks/GpxTracks.swift b/src/Shared/GpxTracks/GpxTracks.swift index 357c0b515..77d8b012e 100644 --- a/src/Shared/GpxTracks/GpxTracks.swift +++ b/src/Shared/GpxTracks/GpxTracks.swift @@ -169,6 +169,9 @@ final class GpxTracks: DiskCacheSizeProtocol { guard let activeTrack else { return } + defer { + WatchGpxSession.shared.update() + } #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) defer { if #available(iOS 16.2, *) { diff --git a/src/Shared/GpxTracks/GpxWatchState.swift b/src/Shared/GpxTracks/GpxWatchState.swift new file mode 100644 index 000000000..8d7742e4a --- /dev/null +++ b/src/Shared/GpxTracks/GpxWatchState.swift @@ -0,0 +1,82 @@ +// +// GpxWatchState.swift +// Go Map!! +// +// Copyright © 2026 Bryce Cogswell. All rights reserved. +// + +import Foundation + +struct GpxWatchState: Codable, Equatable { + enum Status: String, Codable { + case idle + case running + case paused + } + + enum Command: String { + case refresh + case start + case pause + case stop + case history + case delete + case setBackgroundRecording + } + + static let stateKey = "gpxState" + static let historyKey = "gpxHistory" + static let commandKey = "command" + static let traceIdKey = "traceId" + static let enabledKey = "enabled" + + var status: Status = .idle + var startTime: Date? + var endTime: Date? + var pointCount = 0 + var outline: [[Double]] = [] + var recordsInBackground = true + var hasAlwaysLocationAccess = true + + func encoded() -> [String: Any] { + guard let data = try? JSONEncoder().encode(self) else { return [:] } + return [Self.stateKey: data] + } + + init() {} + + init?(_ dictionary: [String: Any]) { + guard let data = dictionary[Self.stateKey] as? Data, + let state = try? JSONDecoder().decode(Self.self, from: data) + else { + return nil + } + self = state + } +} + +struct GpxTraceSummary: Codable, Identifiable, Equatable { + let id: String + let startTime: Date + let duration: TimeInterval + let pointCount: Int + let outline: [[Double]] + + static func encoded(_ traces: [GpxTraceSummary]) -> [String: Any] { + guard let data = try? JSONEncoder().encode(traces) else { return [:] } + return [GpxWatchState.historyKey: data] + } + + static func decoded(_ dictionary: [String: Any]) -> [GpxTraceSummary]? { + guard let data = dictionary[GpxWatchState.historyKey] as? Data else { return nil } + return try? JSONDecoder().decode([GpxTraceSummary].self, from: data) + } +} + +extension Array where Element == [Double] { + func simplified(to maxCount: Int) -> [[Double]] { + guard count > maxCount, maxCount >= 2 else { return self } + let step = Double(count - 1) / Double(maxCount - 1) + return (0..