iPad Support for Driveline (View-Only Companion)
Context
Driveline currently ships iPhone-only (TARGETED_DEVICE_FAMILY = 1), built around a
NavigationStack push flow: HomeView (list) → DriveDetailView (map + scrolling info
cards) → FullScreenMapView. Drives sync automatically via SwiftData + CloudKit
(iCloud.com.targatrips.driveline), so any device signed into the same iCloud account
already receives the full drive history.
We want the app on iPad as a viewer for synced drives. The iPad becomes a distinct
product surface: a NavigationSplitView with the drive list in the sidebar, the map +
route as the hero in the detail pane, and the drive's info (stats, endpoints, weather,
metadata) in a toggleable inspector panel.
Confirmed decisions:
- View-only on iPad. Recording from iPad is an unlikely use case (Wi-Fi iPads have no
GPS). The iPad shows no record button, no onboarding/permission flow, no
recording screen, and the Start/Finish App Intents are not surfaced on iPad. This
keeps the entire recording/location stack iPhone-scoped.
- Toggleable inspector (
.inspector) for the info panel, not a permanent third column —
the map stays the hero.
- Full management parity on the iPad sidebar: multi-select, merge, delete, and the
stats summary panel — reusing the existing iPhone logic, single-sourced.
Guiding principle
The iPhone experience is the high-risk surface. All extraction is extract-then-substitute
(move a member's body into a new reusable view/type, then have the iPhone view call it) so
iPhone output stays behaviourally identical, gated by the existing test suite. The iPad is
additive and idiom-isolated.
Approach
A thin RootView selects the layout by device idiom (not size class):
- iPhone → existing
HomeView (+ onboarding cover + recording modifier), unchanged.
- iPad → new
DrivesSplitView (NavigationSplitView).
Idiom (not horizontalSizeClass) is the switch so an iPad entering Slide Over / narrow
Split View does not flip into the recording-capable iPhone UI mid-session;
NavigationSplitView collapses to a single-column stack on its own in compact width.
Recording is gated structurally: the iPad branch simply never builds any recording,
onboarding, or record-button surface, so there is nothing to hide.
Changes
1. Project config — Driveline.xcodeproj/project.pbxproj
- App target only (the two
com.targatrips.Driveline configs near the
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad lines): change
TARGETED_DEVICE_FAMILY = 1; → TARGETED_DEVICE_FAMILY = "1,2"; in both Debug and Release.
- Leave the
DriveWidgetExtension target at 1 (Live Activity / recording widget is
iPhone-only; iPad never records).
- iPad orientation keys are already present;
SUPPORTED_PLATFORMS stays "iphoneos iphonesimulator"; CloudKit entitlement is unchanged (iPad is just another sync peer).
- Optionally set
DrivelineUITests to "1,2" only if iPad UI tests are added (§7).
2. Root selection
- New
Driveline/UI/Root/RootView.swift — RootView reads
UIDevice.current.userInterfaceIdiom. iPad → DrivesSplitView(). iPhone → HomeView()
with the onboarding .fullScreenCover and the RecordButtonTip/StatsPanelTip/EditDriveTip
onChange attached here (moved out of Driveline.swift). RecordingScreenModifier stays
on HomeView as today (iPad never instantiates HomeView).
- New
Driveline/AppLifecycle/RecordingAvailability.swift — pure, testable helper, e.g.
enum RecordingAvailability { static func isSupported(_ idiom: UIUserInterfaceIdiom) -> Bool }
(.pad → false, else true). Used by RootView, App Intents, and AppBootstrap.
- Edit
Driveline/AppLifecycle/Driveline.swift — WindowGroup content becomes
RootView(isOnboardingPresented: $isOnboardingPresented). Keep the service
.environment(...) injections above RootView, the scenePhase sweep onChange, and
.modelContainer(modelContainer). Keep the existing UI-testing/onboarding-testing gate at
line 38 untouched.
3. Shared management + list content (single-source for full parity)
- New
Driveline/UI/Home/DriveManagementState.swift — @MainActor @Observable final class
holding isSelectMode, selectedDriveIDs: Set<UUID>, drivesToMerge, showingMergeSheet,
showingDeleteConfirmation, plus actions enterSelectMode/exitSelectMode,
toggleSelection(_:), triggerMerge(in:), selectedDrives(in:), delete(_:in:deindexing:).
Bodies move verbatim from HomeView's current @State + action methods. Justified by the
CLAUDE.md "state shared across views not in a parent-child relationship" criterion (HomeView
DrivesSplitView). Deletes reuse DriveDeletion; merge presents existing MergeDrivesView.
- New
Driveline/UI/Home/DriveListContent.swift — the List body extracted from
HomeView.driveList (lines ~132–198): the stats-panel section, ForEach(sections), rows,
onDelete, and select-mode toggling. Parameterised by a DriveListMode enum:
.pushNavigation → rows emit NavigationLink(value:) (iPhone).
.selectionDriven(selection: Binding<UUID?>) → rows are selectable and set the detail
selection (iPad).
In either mode, when isSelectMode is on, rows become toggle buttons (shared). Reuses
DriveSectionBuilder, DriveSection, DriveRowView, HomeStatsPanelView, DriveStats,
HomePresenter. The SelectionToolbar overlay + merge sheet + delete alert stay in each
host view (so the shared content has no recording/merge coupling).
- Edit
Driveline/UI/Home/HomeView.swift — own a DriveManagementState; render
DriveListContent(mode: .pushNavigation). Keep the ZStack(alignment: .bottom) +
SelectionToolbar overlay, HomeToolbar (bottom-bar record button), RecordingScreenModifier,
.navigationDestination(for: Drive.self), and Spotlight navigationPath.append exactly as
today. Behaviour must remain identical.
4. iPad split view
- New
Driveline/UI/iPad/DrivesSplitView.swift — NavigationSplitView:
- Sidebar:
DriveListContent(mode: .selectionDriven(selection: $selectedDriveID)) +
.searchable + .navigationTitle("Drives"), owning a DriveManagementState and rendering
the same SelectionToolbar/merge sheet/delete alert as HomeView (full parity).
- Detail:
if let drive = selectedDrive { DriveViewerView(drive:modelContainer:).id(drive.id) } else { DriveViewerPlaceholderView() }. The .id(drive.id) is load-bearing — it forces
DriveDetailState to rebuild and re-run loadRoute() when the selection changes (the detail
view is otherwise reused and would show the previous drive's route).
selectedDriveID: UUID?; selectedDrive resolves from the live @Query (returns nil if
the drive was deleted → placeholder). Clear selectedDriveID in the delete handler when it
matches.
.onContinueUserActivity(CSSearchableItemActionType) sets selectedDriveID (mirrors
HomeView.openDrive, replacing the path push).
- New
Driveline/UI/iPad/DriveViewerView.swift — wrapped in a NavigationStack (to host the
toolbar + navigationDestination). Full-bleed interactive Map { DriveMapContent(...) }
bound to DriveDetailState.cameraPosition, .task { await driveState.loadRoute() }. Toolbar:
inspector toggle, full-screen map, share Menu, and a more menu (Edit / Delete). .inspector( isPresented:) hosts DriveInfoPanel. Reuses DriveDetailState, DriveDetailPresenter,
EditDriveView, DriveDeletion, FullScreenMapView, ActivityView. Construct
@State driveState = DriveDetailState(drive:modelContainer:) as DriveDetailView does.
- New
Driveline/UI/iPad/DriveViewerPlaceholderView.swift — ContentUnavailableView
("Select a Drive", Icons.Widgets.car) for empty/deleted selection.
5. Shared detail cards (reused by iPhone + iPad inspector)
- New
Driveline/UI/DriveDetail/DriveDetailCards.swift — extract from DriveDetailView's
private members into internal views: DriveHeaderCard, DriveStatTilesRow(drive:),
DriveEndpointsCard(presenter:), ShareDriveButton(state:), WeatherAttributionFooter(state:);
relocate DriveDetailWeatherCard and DriveDetailMetadataCard here (drop private).
- New
Driveline/UI/DriveDetail/DriveInfoPanel.swift — DriveInfoPanel(state:) composing the
cards above from DriveDetailState + DriveDetailPresenter. Single source of truth for the
card stack.
- Edit
Driveline/UI/DriveDetail/DriveDetailView.swift (iPhone) — replace the inline card
bodies with the extracted views (its ScrollView renders DriveInfoPanel(state:)). Keep the
280pt map + glass-button overlay + navigationDestination to FullScreenMapView. Visuals
unchanged. (Bonus: drops the type body well under the 250-line SwiftLint limit.)
- Edit
Driveline/UI/DriveDetail/DriveDetailMapView.swift — add
var interactionModes: MapInteractionModes = [] so iPhone keeps the non-interactive preview map
and iPad passes .all. Default preserves the iPhone call site.
6. Recording / onboarding / App Intents gating
- Onboarding cover + record button +
RecordingScreenModifier are iPhone-branch-only (structural,
via §2/§3) — nothing renders on iPad.
- Edit
Driveline/AppIntents/AppIntents.swift — in DrivelineShortcuts.appShortcuts,
conditionally exclude StartDriveIntent/FinishDriveIntent when
!RecordingAvailability.isSupported(UIDevice.current.userInterfaceIdiom) so they don't appear in
Shortcuts/Siri on iPad.
- Edit
Driveline/AppLifecycle/AppBootstrap.swift — only set IntentDependencyResolver.provider
when recording is supported, so an intent invoked on iPad fails gracefully via the existing
AppIntentDependencyError.notReady path (defence in depth).
7. Tests (Swift Testing)
- New
RecordingAvailabilityTests — .pad → false, .phone → true, .unspecified → true.
- New
RootLayoutTests (if RootView uses a pure mapping helper) — idiom → layout.
- New
DriveManagementStateTests — toggleSelection, enter/exitSelectMode, triggerMerge
(sorts the two drives), selectedDrives(in:), delete behaviour.
- New split-selection / Spotlight-deeplink helper tests —
selectedDrive resolves a valid id,
returns nil for a deleted id; the Spotlight identifier → UUID parse (extract a shared
SpotlightDeepLink.driveID(from:) helper reused by HomeView + DrivesSplitView).
- Unchanged
DriveDetailStateTests, DriveDetailPresenterTests, DriveStatsPresenterTests,
HomeStatsPresenterTests, DriveSectionBuilderTests, MergeDrives*Tests, FullScreenMapStateTests
— these cover the shared logic both layouts reuse; they must still pass after extraction.
- Optional iPad XCUITest: launch on an iPad sim, assert sidebar + placeholder, select a row →
map + inspector appear, toggle inspector, and assert the record button (NewDriveButton) is
absent.
Verification
Build both idioms and run the unit tests:
# iPhone (existing destination)
xcodebuild -project Driveline.xcodeproj -scheme Driveline \
-destination 'platform=iOS Simulator,name=iPhone 17' build test
# iPad
xcodebuild -project Driveline.xcodeproj -scheme Driveline \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' build
Manual pass on an iPad simulator (signed into the same iCloud account so synced drives appear):
- Sidebar lists synced drives; selecting one shows the map + route in the detail pane.
- Inspector toggles open/closed; info cards (stats, endpoints, weather, metadata) render; the
map is the hero.
- Switching drives updates the route/stats (confirms
.id(drive.id) rebuild).
- Share (GPX/PNG), Edit, and Delete work from the detail; full-screen map opens.
- Sidebar management parity: multi-select → merge two drives; swipe/select delete; stats panel
scope toggle (30 days / all-time); search filters.
- Deleting the currently-viewed drive falls back to the placeholder.
- Spotlight: opening a drive from system search selects it in the split view.
- Narrow multitasking (Slide Over / 1⁄3 split) collapses the split view gracefully (no jump to
the iPhone UI, no recording affordances).
- Confirm no record button, no onboarding, and no "Start a drive" shortcut in the
Shortcuts app on iPad.
iPhone regression check: HomeView (record, search, multi-select, merge, stats, tips, recording
cover, Spotlight push) and DriveDetailView look and behave exactly as before.
iPad Support for Driveline (View-Only Companion)
Context
Driveline currently ships iPhone-only (
TARGETED_DEVICE_FAMILY = 1), built around aNavigationStackpush flow:HomeView(list) →DriveDetailView(map + scrolling infocards) →
FullScreenMapView. Drives sync automatically via SwiftData + CloudKit(
iCloud.com.targatrips.driveline), so any device signed into the same iCloud accountalready receives the full drive history.
We want the app on iPad as a viewer for synced drives. The iPad becomes a distinct
product surface: a
NavigationSplitViewwith the drive list in the sidebar, the map +route as the hero in the detail pane, and the drive's info (stats, endpoints, weather,
metadata) in a toggleable inspector panel.
Confirmed decisions:
GPS). The iPad shows no record button, no onboarding/permission flow, no
recording screen, and the Start/Finish App Intents are not surfaced on iPad. This
keeps the entire recording/location stack iPhone-scoped.
.inspector) for the info panel, not a permanent third column —the map stays the hero.
stats summary panel — reusing the existing iPhone logic, single-sourced.
Guiding principle
The iPhone experience is the high-risk surface. All extraction is extract-then-substitute
(move a member's body into a new reusable view/type, then have the iPhone view call it) so
iPhone output stays behaviourally identical, gated by the existing test suite. The iPad is
additive and idiom-isolated.
Approach
A thin
RootViewselects the layout by device idiom (not size class):HomeView(+ onboarding cover + recording modifier), unchanged.DrivesSplitView(NavigationSplitView).Idiom (not
horizontalSizeClass) is the switch so an iPad entering Slide Over / narrowSplit View does not flip into the recording-capable iPhone UI mid-session;
NavigationSplitViewcollapses to a single-column stack on its own in compact width.Recording is gated structurally: the iPad branch simply never builds any recording,
onboarding, or record-button surface, so there is nothing to hide.
Changes
1. Project config —
Driveline.xcodeproj/project.pbxprojcom.targatrips.Drivelineconfigs near theINFOPLIST_KEY_UISupportedInterfaceOrientations_iPadlines): changeTARGETED_DEVICE_FAMILY = 1;→TARGETED_DEVICE_FAMILY = "1,2";in both Debug and Release.DriveWidgetExtensiontarget at1(Live Activity / recording widget isiPhone-only; iPad never records).
SUPPORTED_PLATFORMSstays"iphoneos iphonesimulator"; CloudKit entitlement is unchanged (iPad is just another sync peer).DrivelineUITeststo"1,2"only if iPad UI tests are added (§7).2. Root selection
Driveline/UI/Root/RootView.swift—RootViewreadsUIDevice.current.userInterfaceIdiom. iPad →DrivesSplitView(). iPhone →HomeView()with the onboarding
.fullScreenCoverand theRecordButtonTip/StatsPanelTip/EditDriveTiponChangeattached here (moved out ofDriveline.swift).RecordingScreenModifierstayson
HomeViewas today (iPad never instantiatesHomeView).Driveline/AppLifecycle/RecordingAvailability.swift— pure, testable helper, e.g.enum RecordingAvailability { static func isSupported(_ idiom: UIUserInterfaceIdiom) -> Bool }(
.pad → false, elsetrue). Used byRootView, App Intents, andAppBootstrap.Driveline/AppLifecycle/Driveline.swift—WindowGroupcontent becomesRootView(isOnboardingPresented: $isOnboardingPresented). Keep the service.environment(...)injections aboveRootView, thescenePhasesweeponChange, and.modelContainer(modelContainer). Keep the existing UI-testing/onboarding-testing gate atline 38 untouched.
3. Shared management + list content (single-source for full parity)
Driveline/UI/Home/DriveManagementState.swift—@MainActor @Observable final classholding
isSelectMode,selectedDriveIDs: Set<UUID>,drivesToMerge,showingMergeSheet,showingDeleteConfirmation, plus actionsenterSelectMode/exitSelectMode,toggleSelection(_:),triggerMerge(in:),selectedDrives(in:),delete(_:in:deindexing:).Bodies move verbatim from
HomeView's current@State+ action methods. Justified by theCLAUDE.md "state shared across views not in a parent-child relationship" criterion (
HomeViewDrivesSplitView). Deletes reuseDriveDeletion; merge presents existingMergeDrivesView.Driveline/UI/Home/DriveListContent.swift— theListbody extracted fromHomeView.driveList(lines ~132–198): the stats-panel section,ForEach(sections), rows,onDelete, and select-mode toggling. Parameterised by aDriveListModeenum:.pushNavigation→ rows emitNavigationLink(value:)(iPhone)..selectionDriven(selection: Binding<UUID?>)→ rows are selectable and set the detailselection (iPad).
In either mode, when
isSelectModeis on, rows become toggle buttons (shared). ReusesDriveSectionBuilder,DriveSection,DriveRowView,HomeStatsPanelView,DriveStats,HomePresenter. TheSelectionToolbaroverlay + merge sheet + delete alert stay in eachhost view (so the shared content has no recording/merge coupling).
Driveline/UI/Home/HomeView.swift— own aDriveManagementState; renderDriveListContent(mode: .pushNavigation). Keep theZStack(alignment: .bottom)+SelectionToolbaroverlay,HomeToolbar(bottom-bar record button),RecordingScreenModifier,.navigationDestination(for: Drive.self), and SpotlightnavigationPath.appendexactly astoday. Behaviour must remain identical.
4. iPad split view
Driveline/UI/iPad/DrivesSplitView.swift—NavigationSplitView:DriveListContent(mode: .selectionDriven(selection: $selectedDriveID))+.searchable+.navigationTitle("Drives"), owning aDriveManagementStateand renderingthe same
SelectionToolbar/merge sheet/delete alert asHomeView(full parity).if let drive = selectedDrive { DriveViewerView(drive:modelContainer:).id(drive.id) } else { DriveViewerPlaceholderView() }. The.id(drive.id)is load-bearing — it forcesDriveDetailStateto rebuild and re-runloadRoute()when the selection changes (the detailview is otherwise reused and would show the previous drive's route).
selectedDriveID: UUID?;selectedDriveresolves from the live@Query(returnsnilifthe drive was deleted → placeholder). Clear
selectedDriveIDin the delete handler when itmatches.
.onContinueUserActivity(CSSearchableItemActionType)setsselectedDriveID(mirrorsHomeView.openDrive, replacing the path push).Driveline/UI/iPad/DriveViewerView.swift— wrapped in aNavigationStack(to host thetoolbar +
navigationDestination). Full-bleed interactiveMap { DriveMapContent(...) }bound to
DriveDetailState.cameraPosition,.task { await driveState.loadRoute() }. Toolbar:inspector toggle, full-screen map, share
Menu, and a more menu (Edit / Delete)..inspector( isPresented:)hostsDriveInfoPanel. ReusesDriveDetailState,DriveDetailPresenter,EditDriveView,DriveDeletion,FullScreenMapView,ActivityView. Construct@State driveState = DriveDetailState(drive:modelContainer:)asDriveDetailViewdoes.Driveline/UI/iPad/DriveViewerPlaceholderView.swift—ContentUnavailableView("Select a Drive",
Icons.Widgets.car) for empty/deleted selection.5. Shared detail cards (reused by iPhone + iPad inspector)
Driveline/UI/DriveDetail/DriveDetailCards.swift— extract fromDriveDetailView'sprivate members into internal views:
DriveHeaderCard,DriveStatTilesRow(drive:),DriveEndpointsCard(presenter:),ShareDriveButton(state:),WeatherAttributionFooter(state:);relocate
DriveDetailWeatherCardandDriveDetailMetadataCardhere (dropprivate).Driveline/UI/DriveDetail/DriveInfoPanel.swift—DriveInfoPanel(state:)composing thecards above from
DriveDetailState+DriveDetailPresenter. Single source of truth for thecard stack.
Driveline/UI/DriveDetail/DriveDetailView.swift(iPhone) — replace the inline cardbodies with the extracted views (its
ScrollViewrendersDriveInfoPanel(state:)). Keep the280pt map + glass-button overlay +
navigationDestinationtoFullScreenMapView. Visualsunchanged. (Bonus: drops the type body well under the 250-line SwiftLint limit.)
Driveline/UI/DriveDetail/DriveDetailMapView.swift— addvar interactionModes: MapInteractionModes = []so iPhone keeps the non-interactive preview mapand iPad passes
.all. Default preserves the iPhone call site.6. Recording / onboarding / App Intents gating
RecordingScreenModifierare iPhone-branch-only (structural,via §2/§3) — nothing renders on iPad.
Driveline/AppIntents/AppIntents.swift— inDrivelineShortcuts.appShortcuts,conditionally exclude
StartDriveIntent/FinishDriveIntentwhen!RecordingAvailability.isSupported(UIDevice.current.userInterfaceIdiom)so they don't appear inShortcuts/Siri on iPad.
Driveline/AppLifecycle/AppBootstrap.swift— only setIntentDependencyResolver.providerwhen recording is supported, so an intent invoked on iPad fails gracefully via the existing
AppIntentDependencyError.notReadypath (defence in depth).7. Tests (Swift Testing)
RecordingAvailabilityTests—.pad → false,.phone → true,.unspecified → true.RootLayoutTests(ifRootViewuses a pure mapping helper) — idiom → layout.DriveManagementStateTests—toggleSelection,enter/exitSelectMode,triggerMerge(sorts the two drives),
selectedDrives(in:), delete behaviour.selectedDriveresolves a valid id,returns
nilfor a deleted id; the Spotlight identifier →UUIDparse (extract a sharedSpotlightDeepLink.driveID(from:)helper reused byHomeView+DrivesSplitView).DriveDetailStateTests,DriveDetailPresenterTests,DriveStatsPresenterTests,HomeStatsPresenterTests,DriveSectionBuilderTests,MergeDrives*Tests,FullScreenMapStateTests— these cover the shared logic both layouts reuse; they must still pass after extraction.
map + inspector appear, toggle inspector, and assert the record button (
NewDriveButton) isabsent.
Verification
Build both idioms and run the unit tests:
Manual pass on an iPad simulator (signed into the same iCloud account so synced drives appear):
map is the hero.
.id(drive.id)rebuild).scope toggle (30 days / all-time); search filters.
the iPhone UI, no recording affordances).
Shortcuts app on iPad.
iPhone regression check:
HomeView(record, search, multi-select, merge, stats, tips, recordingcover, Spotlight push) and
DriveDetailViewlook and behave exactly as before.