From c6a6a6fca7034044d0c10dd15114addf4647b2ae Mon Sep 17 00:00:00 2001 From: Kevin Ratzel Date: Thu, 22 Oct 2026 10:13:18 -0400 Subject: [PATCH] Add Apple Watch app for GPX traces The watch app starts, pauses and stops GPX recording on the iPhone, shows the trace being recorded with its point count and duration, lists recent traces (swipe to delete), and can turn on Record in Background. A complication starts a trace or shows the one in progress, and the app stays on screen while recording. Co-Authored-By: Claude Opus 5.5 --- src/App/AppDelegate.swift | 2 + src/App/MainViewController.swift | 1 + src/App/WatchGpxSession.swift | 171 +++++++++ src/GoMap.xcodeproj/project.pbxproj | 355 ++++++++++++++++++ .../xcschemes/GoMapWatch.xcscheme | 75 ++++ src/GoMapWatch/ContentView.swift | 290 ++++++++++++++ src/GoMapWatch/GoMapWatch.entitlements | 10 + src/GoMapWatch/GoMapWatchApp.swift | 20 + src/GoMapWatch/HistoryView.swift | 68 ++++ src/GoMapWatch/Info.plist | 10 + src/GoMapWatch/TraceRuntimeSession.swift | 44 +++ src/GoMapWatch/WatchGpxModel.swift | 137 +++++++ src/GoMapWatch/WatchShared.swift | 39 ++ .../GoMapWatchWidget.entitlements | 10 + src/GoMapWatchWidget/GpxComplication.swift | 127 +++++++ src/GoMapWatchWidget/Info.plist | 11 + src/Shared/GpxTracks/GpxTracks.swift | 3 + src/Shared/GpxTracks/GpxWatchState.swift | 82 ++++ 18 files changed, 1455 insertions(+) create mode 100644 src/App/WatchGpxSession.swift create mode 100644 src/GoMap.xcodeproj/xcshareddata/xcschemes/GoMapWatch.xcscheme create mode 100644 src/GoMapWatch/ContentView.swift create mode 100644 src/GoMapWatch/GoMapWatch.entitlements create mode 100644 src/GoMapWatch/GoMapWatchApp.swift create mode 100644 src/GoMapWatch/HistoryView.swift create mode 100644 src/GoMapWatch/Info.plist create mode 100644 src/GoMapWatch/TraceRuntimeSession.swift create mode 100644 src/GoMapWatch/WatchGpxModel.swift create mode 100644 src/GoMapWatch/WatchShared.swift create mode 100644 src/GoMapWatchWidget/GoMapWatchWidget.entitlements create mode 100644 src/GoMapWatchWidget/GpxComplication.swift create mode 100644 src/GoMapWatchWidget/Info.plist create mode 100644 src/Shared/GpxTracks/GpxWatchState.swift 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..