Architecture overview¶
DashPilot is a SwiftUI app with no third-party runtime dependencies: one application target, plus a widget extension that draws the running shift's Live Activity and holds no logic of its own. The structure is kept flat and explicit; layers are introduced when a concrete problem calls for one.
Layers¶
| Layer | Rule |
|---|---|
Domain |
Framework-independent value types and calculations. No SwiftUI, no SwiftData, and Core Location only as a value the caller supplies |
Models |
SwiftData @Model types, which own the invariants of their own transitions |
Persistence |
The versioned schema, the migration plan and container construction |
Services |
Application services that own state transitions, plus thin adapters over platform frameworks |
Intents |
The App Intents surface: eight short lifecycle actions performed with no screen, over those same services |
App |
SwiftUI entry point, screens and preview fixtures |
Support |
Cross-cutting utilities: logging and launch arguments |
DashPilotActivity |
Value types shared with the widget extension: the Live Activity's snapshot, its control vocabulary and its five intent declarations |
DashPilotWidgets |
The widget extension, which draws that snapshot and nothing else |
Domain types are deliberately free of SwiftUI and SwiftData so calculations can be tested without a container or a rendered view. The file-by-file layout is under Project structure.
Only two files import Core Location: CoreLocationAuthorizationProvider and
CoreLocationTrackingProvider. Everything above them works in the app's own vocabulary, which is
what lets authorization states, capture states and sample filtering be exercised without a device.
Shift lifecycle¶
ShiftService is the only place shifts start, end and are deleted. It holds one ModelContext and
nothing else: no cached shift, no "is a shift running" flag, no state that could disagree with the
store.
At most one shift may be unfinished at a time. The rule is enforced in the service by fetching for a shift without an end timestamp before inserting a new one. A disabled button is presentation, not protection, so the start control is absent while a shift runs and the service still refuses the operation.
Because active state is derived, where "unfinished" means endedAt == nil, relaunch recovery needs
no recovery code. A shift left running when the app was killed is still the only unfinished row when
a new service reads the store, so the same record resumes with its original startedAt.
Shift owns its own transitions. end(at:) rejects ending a shift twice or ending it before it
started, and elapsed time clamps at zero so a backwards device clock cannot produce a negative
duration.
Failure handling¶
- Starting while a shift runs, ending with none running, and deleting a shift that is still running
all throw
ShiftLifecycleErrorcases that the view turns into an alert. A rejected tap is never silently dropped. - A failed
save()is followed byrollback(), so an in-memory object never claims a state the store does not record. - If the device clock has moved behind the recorded start,
endActiveShift(at:)clamps the end to the start and logs it. - Finding more than one unfinished shift is treated as a damaged store: the most recent is reported as active, the anomaly is logged as a fault, and starting another shift is still refused.
The root view reads both lists through @Query (unfinished shifts, completed shifts) and calls the
service to mutate, so SwiftData stays the single source of truth for what is displayed. Elapsed time
is rendered by a TimelineView that recomputes Shift.elapsed(asOf:) each second; no changing
duration is stored, and VoiceOver reads the value to the minute rather than announcing seconds.
Delivery lifecycle¶
DeliveryService is the only place deliveries start, advance and finish. It is shaped exactly like
ShiftService: one ModelContext, no cached state, every rule checked against the store.
Any number of deliveries may be active at once, where active means deliveredAt == nil &&
cancelledAt == nil. That is what stacked delivery work is, so every mutation takes the delivery it
applies to as a parameter — markPickedUp(_:at:), cancelDelivery(_:at:) — and none of them
resolves a target internally. With two active, "the active delivery" is not something the service
could resolve, and resolving one anyway would attach a driver's tap to a record they did not mean.
activeDeliveries() and activeDeliveries(for:) are queries for presentation and recovery, not
mutation seams. Both order by acceptance ascending, with the record's identity breaking a tie, rather
than trusting the fetch's own order.
Delivery owns its own transitions and refuses a skipped step, a repeated event, any transition
after a terminal state, and a timestamp earlier than the last recorded event. Its state is derived
from which timestamps exist, so nothing persisted can disagree with the events it summarises.
The two services meet at exactly one point: endActiveShift(at:) refuses to end a shift whose
activeDeliveries is not empty, and the refusal carries the count so the message can be pluralised.
That is the whole coupling. DeliveryService does not know about route capture, and ShiftService
does not know how a delivery advances.
Failure handling¶
- Starting a delivery outside a running shift, advancing a delivery whose shift has already ended,
and every refused transition throw
DeliveryLifecycleErrorcases the view turns into an alert. - A failed
save()is followed byrollback(), so an in-memory delivery never claims an event the store does not record. - An event timestamped before the previous one is clamped forward and logged, the same rule
endActiveShift(at:)applies to a backwards clock. A clamped event produces a zero-length interval, never a negative one. - Several unfinished deliveries are expected, not a damaged store. What is impossible is an active delivery attached to a shift that has ended, or to no shift at all: that is logged as a fault, refused for further transitions, and otherwise left exactly as it is. Nothing is closed, cancelled, deleted or reparented to tidy it up, because each of those would invent a fact about work the driver did.
Correcting grouping¶
OfferCorrectionService is the only place a delivery's offer changes after it was recorded, and the
only place an offer is removed. It owns four corrections and builds all of them from one model
primitive, Delivery.move(into:), rather than from four write paths that can drift apart: a merge is
that applied to every delivery of an offer, followed by removing the offer left holding nothing.
It moves membership and nothing else. No lifecycle timestamp, pickup place, amount or terminal state
moves with it, and neither acceptance timestamp is rewritten. The single ordering rule is
Offer.couldHaveContained(_:), which refuses to put a delivery into an offer accepted after that
delivery was; the destinations a screen lists are filtered by the same rule, so a destination that
would be refused is never offered. An offer created by a split takes the earliest acceptance among
the deliveries moving into it, which is a moment the driver really recorded.
Each correction is one save with the same rollback rule the lifecycle transitions use, so no partly
merged pair of offers can reach the store. Removing an emptied offer is guarded twice, because
Offer.deliveries cascades: an offer is deleted only once that relationship is genuinely empty, and
one that still lists a delivery is left standing and logged as the fault it is.
Unlike a lifecycle transition it is allowed on a finished shift, for the reason recording a delivery's gross amount is: it performs no transition, and history is where a grouping mistake is noticed. It never moves a delivery to another shift.
Correcting a shift's end¶
ShiftEndCorrectionService is the only place Shift.endedAt is written after end(at:) recorded it,
and the only place route positions are deleted outside deleting a whole shift. The rule it enforces is
a plain value, ShiftEndCorrection, which takes the proposed instant plus the shift's start, recorded
end, pauses, delivery lifecycle instants and the next shift's start, and either constructs or refuses.
The screen asks the same value what it would be refused for while the picker moves, so the sentence a
driver reads and the rule the write consults cannot drift apart.
The service writes the end and, when the end moves earlier, deletes the samples fixed after it,
then saves once. There is therefore no ordering in which a shift's end moves while its positions
survive, or the reverse — the same guarantee, and the same fetch-then-delete shape rather than a batch
delete, that ShiftService.deleteCompletedShift(_:) keeps.
Nothing is recalculated by this service. Every figure a corrected shift reports is derived on
demand as it always was: the durations from the two timestamps, the mileage from
RouteMileageCalculator over the positions that remain and the corrected window, the rates from
ShiftMetricsCalculator, the period totals from PeriodMetricsCalculator. That is what makes "the
mileage is measured again rather than scaled" a property of the architecture rather than a promise:
there is no second distance anywhere for a proportional one to be written into.
Like the other corrections it is allowed only on a finished shift, performs no lifecycle transition, touches no amount, no delivery timestamp and no pause timestamp, and reconciles neither route capture nor the Live Activity, because both are about a running shift.
System surfaces: App Intents¶
Eight intents (start, end, pause and resume a shift; park the vehicle and drive again; start a
delivery; record the next delivery event) can be performed by voice, from Shortcuts or from
Spotlight, with the app never coming to the screen.
Each
declares supportedModes as .background, which is where that guarantee lives and what
openAppWhenRun = false said before iOS 26 deprecated it. They exist for driving safety: the
timestamp recorded at the moment the driver says so is the accurate one.
IntentLifecycleService is the only type they call, and it owns no lifecycle logic. It calls
ShiftService and DeliveryService, carries their refusals through unchanged so a driver hears the
same sentence they would read, and adds exactly one rule of its own.
That rule is which delivery a spoken step meant. It is about deliveries alone: the two parked actions
are shift operations, read no delivery in either direction and are refused by no number of them,
because one driver has one vehicle however many orders are in the car. On screen the question does not arise: with three
deliveries running there are three cards, each with its own button. A sentence has no card, so a step
is recorded only while exactly one delivery is in progress; with more, nothing is recorded and the
refusal names the count. That is DeliveryService's own principle, where every mutation takes its
delivery as a parameter and nothing resolves a target internally, applied where there is nobody to
supply one.
Two consequences worth stating:
- Every process shares one container.
AppModelContaineropens it lazily and hands the same instance to the scene and to the intents, so a shift started by voice while DashPilot is on screen is visible to the@Querybehind that screen. Two containers over one file would leave the interface offering to end a shift that had already ended. It is also what makes a background launch ordinary: when iOS starts the process only to run an intent, the intent's first access opens the store. - Route capture is unaffected. Nothing in the intent layer starts, stops or knows about capture. While the app is open, the root screen reconciles capture when the active shift changes, whoever changed it; while it is not, a recording cannot be started at all, so a shift begun by voice records no route until the app is opened, and every spoken confirmation of a start says so.
What the intents deliberately cannot do (cancel a delivery, record an amount, a cost, or a pickup name) is described under Voice and system actions.
System surfaces: the shift's Live Activity¶
A running shift puts one card on the Lock Screen, and the widget extension that draws it is a
renderer with no store, no services and no lifecycle rules. Everything it draws comes out of a
snapshot the app derived; everything it can do runs a LiveActivityIntent, which is performed in
the app's process through IntentLifecycleService, so a shift paused from the Lock Screen is
refused by the same rule, with the same sentence, as one paused by voice or by the button in the app.
ShiftLiveActivityService keeps the card in step, and has one entry point: reconcile(), which
derives what should be on screen from the store rather than from what happened last. That is the
shape LocationTrackingService.synchronize() has, and for the same reason: a missed call costs a
delay and never a wrong state, so every caller is one line and no caller has to know which
transition it is in the middle of. Nothing is read back from ActivityKit to decide what happened;
the only thing asked of it is which cards exist and which shift each says it is about, which is what
lets a relaunch adopt the card its shift already has and lets a card left behind by a finished shift
be removed. See The shift on the Lock Screen.
One consequence is worth stating beside the intents' own: route capture drives the cadence while
the app is off screen. LocationTrackingService reports that samples reached the store, and that
is the only signal available once the driver has locked the phone, which is where most of a shift is
recorded. It is a notification and not an instruction: capture does not wait on it, and what the
Live Activity does with it is decided by reading the store.
Nothing derived is stored¶
Recorded mileage, all three rates, a delivery's state, its two derived intervals, the shift's
delivery active time, its estimated fuel and its estimated net after fuel, and the wording that
qualifies all of them, are computed on demand from the shift's timestamps, its recorded amount, the
fuel assumptions it recorded, its retained route and its deliveries. The fuel estimate is the clearest
case: the store holds the two assumptions and nothing else, so correcting the shift's end moves the
route, the mileage is measured again, and the estimate follows with no fuel code involved. A stored
hourlyRate, a stored distance, a stored activeDuration or a stored delivery state would be a
second answer to a question the store can already answer: it would keep the old number after the
calculation improved, and it would have to be rewritten every time the driver edited an amount or
recorded an event.
If measuring a long route ever proves too slow to do on demand, caching is a deliberate change to make then, with a measurement behind it.
Concurrency¶
The project builds with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so views and view state are
main-actor isolated without annotation.
Domain types (Money, MoneyInput, Shift, RouteSample, Delivery, DeliveryState,
DeliveryAction, DeliverySummary, LocationSample, RoutePoint, RouteSampleFilter,
RouteCaptureState, RouteDistance, RouteMileageCalculator, GeographicDistance,
ShiftMetrics, ShiftMetricsCalculator, the error enums) and infrastructure
(AppLog, ModelContainerFactory, LaunchArgument) are explicitly nonisolated. They carry no UI
state, they are used from tests that are not main-actor bound, and background persistence work will
need them off the main actor.
IntentLifecycleService and every intent's perform() method are main-actor isolated for the
same reason the services are: an intent performs one lifecycle operation over the main context, and
it must not interleave with the interface doing the same.
ShiftService and DeliveryService are deliberately the opposite. Both are @MainActor because
they drive the main context the views observe, and that isolation is what serialises lifecycle
operations: each operation runs to completion without suspending, so two concurrent callers cannot
interleave the check with the insert that follows it. That is the whole concurrency story for a single-user on-device app, and no
locking is added beyond it. LocationTrackingService is main-actor isolated for the same reason,
which is what keeps a location callback from interleaving with a shift transition.
Core Location delivers delegate callbacks on the run loop its manager was created on. Both providers
create their manager on the main actor and use MainActor.assumeIsolated in the callback: a
documented guarantee rather than a proof, and the same assumption throughout the location layer.
Failure is a state, not a crash¶
Container creation throws instead of trapping. AppModelContainer opens the process's container
once, lazily, and DashPilotApp renders PersistenceUnavailableView on failure. An intent that
finds the same failure records nothing and says that nothing was recorded. That screen intentionally offers no "reset the
database" action: recorded shifts cannot be reconstructed from memory, so destroying them is not an
acceptable one-tap recovery.