The app has two modes for a route: editing and navigating. The current design splits this across CurrentRouteState (route definition + editing) and NavigateToState (live navigation engine), with NavigationInfo as a per-tick geometry snapshot. The complexity comes from duplicated fields, manual sync, and identity data mixed into geometry structs.
nextWaypointIDexists on both states, kept in sync by 5+ write sitesNavigationInfomixes geometry with identity —nextWaypoint,currentCoordinate,currentPositionIndexare all available elsewhere- 3 views independently recompute
currentPositionIndexwith identicalfirstIndex(where:)logic - Views fork on
isNavigatingto pick betweenNavigationInfo.nextWaypointandCurrentRouteState.nextWaypointeven though they're the same value - Steer-line caching bookkeeping (
routedSteerLine,isLoadingSteerLine, target/origin coordinates) lives onNavigateToStatebut is only used by one operation
The single source of truth for the route definition and the user's position within it. Persisted to disk.
class RouteState: ObservableObject {
// Route definition
var points: [DrawingPoint] = [] {
didSet {
waypoints = BuildWaypoints.call(routePoints: points)
isSaved = false
objectWillChange.send()
}
}
@Published var routeMode: RouteMode = .auto
@Published var draft = Measurement(value: 2, unit: UnitLength.meters)
@Published var segments: [TrackSegment] = []
@Published var unnavigableSegments: [TrackSegment] = []
@Published var navigableLine: LineString?
@Published var isLoadingSegments = false
// Route metadata
var drawingID: UUID?
var name: String?
var notes = ""
@Published var isSaved = true
// User's position within the route (single source of truth)
@Published var nextWaypointID: UUID? // THE one and only copy
@Published var selectedPointID: UUID?
@Published var isDragging = false
// Derived (not persisted)
private(set) var waypoints: [Waypoint] = []
// Computed conveniences
var nextWaypoint: Waypoint? {
waypoints.first { $0.id == nextWaypointID }
}
var nextWaypointIndex: Int? { // NEW: replaces 3 duplicated view computations
guard let nextWaypointID else { return nil }
return waypoints.firstIndex { $0.id == nextWaypointID }
}
var selectedWaypoint: Waypoint? {
waypoints.first { $0.id == selectedPointID }
}
var isRouteEmpty: Bool { waypoints.isEmpty }
var isRouteReady: Bool { waypoints.hasAtLeastTwo }
}What changed from CurrentRouteState:
- Renamed to
RouteState(it IS the route, "current" is implied) - Added
nextWaypointIndexcomputed property (eliminates duplication in 3 views) - Everything else stays the same — this is already well-structured
The live navigation engine. Transient — nothing persisted. Only meaningful when phase == .start.
final class NavigationState: ObservableObject {
enum Phase {
case start
case pause
case stop
}
@Published var phase = Phase.stop
@Published var geometry: NavigationGeometry? // was `currentInfo: NavigationInfo?`
// Steer line async state (only used by UpdateSteerLineRoute operation)
@Published var routedSteerLine: LineString?
@Published var isLoadingSteerLine = false
var routedSteerTargetCoordinate: Coordinate?
var routedSteerOriginCoordinate: Coordinate?
// Arrival tracking
var arrivalConfirmationCount = 0
var lastNotifiedWaypointID: UUID?
var isNavigating: Bool { phase == .start }
// REMOVED: nextWaypointID (use routeState.nextWaypointID instead)
}What changed from NavigateToState:
- Renamed to
NavigationState(clearer) - Removed
nextWaypointID—RouteState.nextWaypointIDis the single source of truth - Renamed
currentInfo→geometryto clarify what it holds
A per-tick geometry snapshot. Pure computed values — no identity, no lookups.
struct NavigationGeometry {
// Track geometry
let trackCoordinate: Coordinate // closest point on route to boat
let steerCoordinate: Coordinate // intercept point for smooth course
// Precomputed lines for map rendering
let completedLine: LineString? // start → trackCoordinate
let crossTrackErrorLine: LineString? // boat → trackCoordinate (straight)
let crossTrackErrorDirection: CrossTrackErrorDirection?
let steerLine: LineString? // boat → steerCoordinate (smooth curve)
let toEndLine: LineString? // steerLine + (steerCoordinate → end)
let toNextLine: LineString? // steerLine + (steerCoordinate → next waypoint)
// Precomputed dashboard values
let courseToSteerInDegrees: Double
let distanceToEndInMeters: Double
let routeCrossTrackDistance: Double
// REMOVED: nextWaypoint (use routeState.nextWaypoint)
// REMOVED: currentCoordinate (use locationState.currentPosition)
// REMOVED: currentPositionIndex (use routeState.nextWaypointIndex)
}What changed from NavigationInfo:
- Renamed to
NavigationGeometry(says what it is) - Removed
nextWaypoint— available asrouteState.nextWaypoint - Removed
currentCoordinate— available fromlocationState/locationPuckState - Removed
currentPositionIndex— available asrouteState.nextWaypointIndex - What remains is purely geometry: coordinates, lines, distances, bearings
// Before
navState.nextWaypointID = nextWaypointID
navState.arrivalConfirmationCount = 0
appState.currentRouteState.nextWaypointID = nextWaypointID
appState.currentRouteState.selectedPointID = nextWaypointID// After
appState.routeState.nextWaypointID = nextWaypointID
appState.routeState.selectedPointID = nextWaypointID
appState.navigationState.arrivalConfirmationCount = 0// Before
navState.nextWaypointID = determineNextWaypointID(appState: appState)
appState.currentRouteState.nextWaypointID = navState.nextWaypointID // sync!// After
appState.routeState.nextWaypointID = determineNextWaypointID(appState: appState)// Before — reads navState.nextWaypointID, bundles nextWaypoint into the struct
let nextWaypoint = waypoints.first(where: { $0.id == navState.nextWaypointID }) ?? waypoints.first
// ... later ...
return NavigationInfo(nextWaypoint: nextWaypoint, currentCoordinate: coord, ...)// After — reads routeState.nextWaypointID, doesn't bundle identity into the struct
let nextWaypoint = appState.routeState.nextWaypoint ?? waypoints.first
// uses nextWaypoint.coordinate for geometry calculations, but doesn't store it in the result
return NavigationGeometry(trackCoordinate: ..., steerCoordinate: ..., ...)private var waypointLabel: String? {
if isNavigating {
return navigateToState.currentInfo?.nextWaypoint.pinLabel
}
return currentRouteState.nextWaypoint?.pinLabel
}private var waypointLabel: String? {
routeState.nextWaypoint?.pinLabel
}// CurrentRouteBottomSheet, MiniMapContent, WaypointsTableSection — all identical
private var currentPositionIndex: Int? {
guard let nextID = currentRouteState.nextWaypointID else { return nil }
return currentRouteState.waypoints.firstIndex(where: { $0.id == nextID })
}// Views just use:
routeState.nextWaypointIndexThese changes can be done incrementally without breaking anything:
| Step | What | Risk |
|---|---|---|
| 1 | Add nextWaypointIndex to CurrentRouteState, use it in 3 views |
None |
| 2 | Remove NavigateToState.nextWaypointID, read from currentRouteState everywhere |
Low |
| 3 | Remove nextWaypoint, currentCoordinate, currentPositionIndex from NavigationInfo |
Medium |
| 4 | Rename types (RouteState, NavigationState, NavigationGeometry) |
Low (mechanical) |
Each step is independently shippable and testable.