Skip to content

Investigate architecture required for iPad version #147

Description

@dglancy

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.swiftRootView 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.swiftWindowGroup 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.swiftNavigationSplitView:
    • 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.swiftContentUnavailableView
    ("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.swiftDriveInfoPanel(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 DriveManagementStateTeststoggleSelection, 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):

  1. Sidebar lists synced drives; selecting one shows the map + route in the detail pane.
  2. Inspector toggles open/closed; info cards (stats, endpoints, weather, metadata) render; the
    map is the hero.
  3. Switching drives updates the route/stats (confirms .id(drive.id) rebuild).
  4. Share (GPX/PNG), Edit, and Delete work from the detail; full-screen map opens.
  5. Sidebar management parity: multi-select → merge two drives; swipe/select delete; stats panel
    scope toggle (30 days / all-time); search filters.
  6. Deleting the currently-viewed drive falls back to the placeholder.
  7. Spotlight: opening a drive from system search selects it in the split view.
  8. Narrow multitasking (Slide Over / 1⁄3 split) collapses the split view gracefully (no jump to
    the iPhone UI, no recording affordances).
  9. 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

Status
In progress

Relationships

None yet

Development

No branches or pull requests

Issue actions