From 40c5f896312b7f44ed50a7e3abe1acc2b3b7af16 Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 10:54:23 +0100 Subject: [PATCH 1/8] #147 Initial ipad version --- Driveline.xcodeproj/project.pbxproj | 4 +- Driveline/AppLifecycle/AppBootstrap.swift | 2 + Driveline/AppLifecycle/Driveline.swift | 15 +- Driveline/AppLifecycle/Localizable.xcstrings | 18 +- .../AppLifecycle/RecordingAvailability.swift | 14 + .../UI/DriveDetail/DriveDetailCards.swift | 261 ++++++++++++++++++ .../UI/DriveDetail/DriveDetailMapView.swift | 3 +- .../UI/DriveDetail/DriveDetailView.swift | 237 +--------------- Driveline/UI/DriveDetail/DriveInfoPanel.swift | 37 +++ Driveline/UI/Home/DriveListContent.swift | 121 ++++++++ Driveline/UI/Home/DriveManagementState.swift | 62 +++++ Driveline/UI/Home/HomeView.swift | 152 +++------- Driveline/UI/Root/RootView.swift | 41 +++ .../UI/iPad/DriveViewerPlaceholderView.swift | 18 ++ Driveline/UI/iPad/DriveViewerView.swift | 160 +++++++++++ Driveline/UI/iPad/DrivesSplitView.swift | 139 ++++++++++ .../RecordingAvailabilityTests.swift | 30 ++ .../UITests/DriveManagementStateTests.swift | 158 +++++++++++ 18 files changed, 1092 insertions(+), 380 deletions(-) create mode 100644 Driveline/AppLifecycle/RecordingAvailability.swift create mode 100644 Driveline/UI/DriveDetail/DriveDetailCards.swift create mode 100644 Driveline/UI/DriveDetail/DriveInfoPanel.swift create mode 100644 Driveline/UI/Home/DriveListContent.swift create mode 100644 Driveline/UI/Home/DriveManagementState.swift create mode 100644 Driveline/UI/Root/RootView.swift create mode 100644 Driveline/UI/iPad/DriveViewerPlaceholderView.swift create mode 100644 Driveline/UI/iPad/DriveViewerView.swift create mode 100644 Driveline/UI/iPad/DrivesSplitView.swift create mode 100644 DrivelineTests/AppLifecycleTests/RecordingAvailabilityTests.swift create mode 100644 DrivelineTests/UITests/DriveManagementStateTests.swift diff --git a/Driveline.xcodeproj/project.pbxproj b/Driveline.xcodeproj/project.pbxproj index 9047ffb..bf4ca1c 100644 --- a/Driveline.xcodeproj/project.pbxproj +++ b/Driveline.xcodeproj/project.pbxproj @@ -904,7 +904,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -952,7 +952,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/Driveline/AppLifecycle/AppBootstrap.swift b/Driveline/AppLifecycle/AppBootstrap.swift index 81be4b5..fa7a9a2 100644 --- a/Driveline/AppLifecycle/AppBootstrap.swift +++ b/Driveline/AppLifecycle/AppBootstrap.swift @@ -9,6 +9,7 @@ import BackgroundTasks import Foundation import SwiftData import TipKit +import UIKit @MainActor enum AppBootstrap { @@ -131,6 +132,7 @@ enum AppBootstrap { } private static func registerIntentDependencies(driveService: DriveRecordingService) { + guard RecordingAvailability.isSupported(UIDevice.current.userInterfaceIdiom) else { return } Log.lifecycle.info("Registering dependencies for App Intents") IntentDependencyResolver.provider = { driveService } } diff --git a/Driveline/AppLifecycle/Driveline.swift b/Driveline/AppLifecycle/Driveline.swift index aeb0b46..26d82dc 100644 --- a/Driveline/AppLifecycle/Driveline.swift +++ b/Driveline/AppLifecycle/Driveline.swift @@ -49,23 +49,10 @@ struct Driveline: App { var body: some Scene { WindowGroup { - HomeView() + RootView(isOnboardingPresented: $isOnboardingPresented) .environment(locationService) .environment(driveService) .environment(spotlightIndexingService) - .sheet(isPresented: $isOnboardingPresented) { - OnboardingWelcomeView { - var prefs = UserPreferences() - prefs.setHasSeenWelcome(true) - isOnboardingPresented = false - } - .interactiveDismissDisabled() - } - .onChange(of: isOnboardingPresented, initial: true) { _, isPresented in - RecordButtonTip.isOnboardingPresented = isPresented - StatsPanelTip.isOnboardingPresented = isPresented - EditDriveTip.isOnboardingPresented = isPresented - } .onChange(of: scenePhase) { switch scenePhase { case .active: diff --git a/Driveline/AppLifecycle/Localizable.xcstrings b/Driveline/AppLifecycle/Localizable.xcstrings index 535938a..3a63eae 100644 --- a/Driveline/AppLifecycle/Localizable.xcstrings +++ b/Driveline/AppLifecycle/Localizable.xcstrings @@ -1721,8 +1721,7 @@ } }, "Drives" : { - "comment" : "A title for the list of drives.", - "isCommentAutoGenerated" : true, + "comment" : "Navigation title for drives list", "localizations" : { "de" : { "stringUnit" : { @@ -2540,7 +2539,7 @@ } }, "Full screen map" : { - "comment" : "Accessibility label for the button that opens the full screen map on the drive detail screen", + "comment" : "Accessibility label for the button that opens the full screen map on the drive detail screen\nFull screen map button accessibility label", "localizations" : { "de" : { "stringUnit" : { @@ -2975,7 +2974,7 @@ } }, "More options" : { - "comment" : "Accessibility label for the more options button on the drive detail screen\nEllipsis menu accessibility label", + "comment" : "Accessibility label for the more options button on the drive detail screen\nEllipsis menu accessibility label\nMore options button accessibility label", "localizations" : { "de" : { "stringUnit" : { @@ -3866,8 +3865,7 @@ } }, "Search" : { - "comment" : "A label for a search bar.", - "isCommentAutoGenerated" : true, + "comment" : "Search field prompt", "localizations" : { "de" : { "stringUnit" : { @@ -3924,6 +3922,9 @@ } } }, + "Select a Drive" : { + "comment" : "iPad placeholder when no drive is selected" + }, "Select Drives" : { "comment" : "Menu item to enter multiselect mode", "localizations" : { @@ -4157,7 +4158,7 @@ } }, "Share Drive" : { - "comment" : "Share button", + "comment" : "Share button\nShare button accessibility label", "localizations" : { "de" : { "stringUnit" : { @@ -5062,6 +5063,9 @@ } } }, + "Toggle drive info panel" : { + "comment" : "Inspector toggle button accessibility label" + }, "Top Speed" : { "comment" : "Metadata row", "localizations" : { diff --git a/Driveline/AppLifecycle/RecordingAvailability.swift b/Driveline/AppLifecycle/RecordingAvailability.swift new file mode 100644 index 0000000..553527a --- /dev/null +++ b/Driveline/AppLifecycle/RecordingAvailability.swift @@ -0,0 +1,14 @@ +// +// RecordingAvailability.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import UIKit + +enum RecordingAvailability { + static func isSupported(_ idiom: UIUserInterfaceIdiom) -> Bool { + idiom != .pad + } +} diff --git a/Driveline/UI/DriveDetail/DriveDetailCards.swift b/Driveline/UI/DriveDetail/DriveDetailCards.swift new file mode 100644 index 0000000..1bd29fb --- /dev/null +++ b/Driveline/UI/DriveDetail/DriveDetailCards.swift @@ -0,0 +1,261 @@ +// +// DriveDetailCards.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import CoreLocation +import SwiftUI + +// MARK: - DriveHeaderCard + +struct DriveHeaderCard: View { + + let presenter: DriveDetailPresenter + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(presenter.name) + .font(.title.weight(.bold)) + .foregroundStyle(Color(.label)) + .lineLimit(2) + .minimumScaleFactor(0.1) + .dynamicTypeSize(.xSmall ... .accessibility1) + Text(presenter.dateString) + .font(.callout) + .foregroundStyle(.secondary) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + } +} + +// MARK: - DriveStatTilesRow + +struct DriveStatTilesRow: View { + + let drive: Drive + + var body: some View { + let stats = DriveStatsPresenter(drive: drive) + HStack(spacing: 10) { + DriveStatTile( + icon: "ruler", + label: String(localized: "Distance", comment: "Stat tile label"), + value: stats.distanceValue, + unit: stats.distanceUnit + ) + DriveStatTile( + icon: "clock", + label: String(localized: "Duration", comment: "Stat tile label"), + value: stats.durationValue, + unit: stats.durationUnit + ) + DriveStatTile( + icon: "speedometer", + label: String(localized: "Avg Speed", comment: "Stat tile label"), + value: stats.avgSpeedValue, + unit: stats.avgSpeedUnit + ) + } + } +} + +// MARK: - DriveEndpointsCard + +struct DriveEndpointsCard: View { + + let presenter: DriveDetailPresenter + + var body: some View { + VStack(spacing: 0) { + IconRow( + title: presenter.startPlace ?? String(localized: "Unknown", comment: "Unknown place name"), + subtitle: String(localized: "Departure", comment: "Endpoint row subtitle"), + trailing: presenter.departureTime + ) { + Circle() + .fill(Color.green) + .frame(width: 13, height: 13) + .overlay(Circle().stroke(Color(.systemBackground), lineWidth: 2)) + .shadow(color: .black.opacity(0.15), radius: 1) + } + + Divider().padding(.leading, 52) + + IconRow( + title: presenter.endPlace ?? String(localized: "Unknown", comment: "Unknown place name"), + subtitle: String(localized: "Arrival", comment: "Endpoint row subtitle"), + trailing: presenter.arrivalTime + ) { + Image(systemName: Icons.Drive.finishFlag) + .font(.body.weight(.medium)) + .foregroundStyle(.red) + .dynamicTypeSize(.xSmall ... .xxxLarge) + } + } + .cardBackground(cornerRadius: 16) + } +} + +// MARK: - DriveDetailWeatherCard + +struct DriveDetailWeatherCard: View { + + let presenter: DriveDetailPresenter + let onLoadAttribution: () -> Void + + var body: some View { + if presenter.hasWeather { + VStack(spacing: 0) { + if let symbol = presenter.startWeatherSymbol, + let description = presenter.startWeatherDescription { + IconRow( + title: description, + subtitle: String(localized: "At Departure", comment: "Weather row subtitle"), + trailing: presenter.startWeatherTemperature + ) { + Image(systemName: symbol) + .symbolRenderingMode(.multicolor) + .font(.callout) + .frame(width: 24) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + } + + if let symbol = presenter.endWeatherSymbol, + let description = presenter.endWeatherDescription { + Divider().padding(.leading, 52) + IconRow( + title: description, + subtitle: String(localized: "At Arrival", comment: "Weather row subtitle"), + trailing: presenter.endWeatherTemperature + ) { + Image(systemName: symbol) + .symbolRenderingMode(.multicolor) + .font(.callout) + .frame(width: 24) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + } + } + .cardBackground(cornerRadius: 16) + .task { onLoadAttribution() } + } + } +} + +// MARK: - DriveDetailMetadataCard + +struct DriveDetailMetadataCard: View { + + let presenter: DriveDetailPresenter + let maxSpeedMPS: CLLocationSpeed + let positionCount: Int + + var body: some View { + VStack(spacing: 0) { + if presenter.hasCategory { + IconRow(title: String(localized: "Category", comment: "Metadata row"), trailing: presenter.categoryDisplayName) { + Image(systemName: Icons.Stats.category) + .font(.callout) + .foregroundStyle(.secondary) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + + Divider().padding(.leading, 52) + } + + IconRow(title: String(localized: "Top Speed", comment: "Metadata row"), trailing: presenter.topSpeed(maxSpeedMPS: maxSpeedMPS)) { + Image(systemName: Icons.Stats.speed) + .font(.callout) + .foregroundStyle(.secondary) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + + Divider().padding(.leading, 52) + + IconRow(title: String(localized: "Track Points", comment: "Metadata row"), trailing: presenter.trackPoints(count: positionCount)) { + Image(systemName: Icons.Stats.location) + .font(.callout) + .foregroundStyle(.secondary) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + + Divider().padding(.leading, 52) + + IconRow(title: String(localized: "Started by", comment: "Metadata row"), trailing: presenter.triggerDisplayName) { + Image(systemName: Icons.Stats.gpsSignal) + .font(.callout) + .foregroundStyle(.secondary) + .dynamicTypeSize(.xSmall ... .accessibility1) + } + } + .cardBackground(cornerRadius: 16) + } +} + +// MARK: - ShareDriveButton + +struct ShareDriveButton: View { + + let state: DriveDetailState + + var body: some View { + Menu { + Button { + Task { await state.share(.gpx) } + } label: { + Label(String(localized: "Share as GPX", comment: "Share drive as GPX"), systemImage: Icons.Options.gpxFile) + } + Button { + Task { await state.share(.png) } + } label: { + Label(String(localized: "Share as PNG", comment: "Share drive as PNG"), systemImage: Icons.Options.pngImage) + } + } label: { + Label(String(localized: "Share Drive", comment: "Share button"), systemImage: Icons.Options.sharing) + .font(.body.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .overlay(alignment: .trailing) { + if state.isPreparingExport { + ProgressView().padding(.trailing, 16) + } + } + } + .disabled(!state.canExport || state.isPreparingExport) + .cardBackground(cornerRadius: 16) + } +} + +// MARK: - WeatherAttributionFooter + +struct WeatherAttributionFooter: View { + + let state: DriveDetailState + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + if let legalURL = state.weatherAttributionLegalURL, + let lightMarkURL = state.weatherAttributionLightMarkURL, + let darkMarkURL = state.weatherAttributionDarkMarkURL { + VStack(spacing: 4) { + Link(destination: legalURL) { + AsyncImage(url: colorScheme == .dark ? darkMarkURL : lightMarkURL) { image in + image.resizable().scaledToFit() + } placeholder: { + EmptyView() + } + .frame(height: 14) + } + Link(String(localized: "Weather data provided by Apple Weather", comment: "Weather attribution footer link"), destination: legalURL) + .font(.caption2) + .foregroundStyle(Color(.secondaryLabel)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + } +} diff --git a/Driveline/UI/DriveDetail/DriveDetailMapView.swift b/Driveline/UI/DriveDetail/DriveDetailMapView.swift index 7f0600f..b500c9e 100644 --- a/Driveline/UI/DriveDetail/DriveDetailMapView.swift +++ b/Driveline/UI/DriveDetail/DriveDetailMapView.swift @@ -16,11 +16,12 @@ struct DriveDetailMapView: View { let segments: [[CLLocationCoordinate2D]] @Binding var cameraPosition: MapCameraPosition let accessibilityLabel: String + var interactionModes: MapInteractionModes = [] // MARK: - Body var body: some View { - Map(position: $cameraPosition, interactionModes: []) { + Map(position: $cameraPosition, interactionModes: interactionModes) { DriveMapContent(segments: segments) } .mapStyle(.standard(emphasis: .muted)) diff --git a/Driveline/UI/DriveDetail/DriveDetailView.swift b/Driveline/UI/DriveDetail/DriveDetailView.swift index c23bfc5..d8ccaf9 100644 --- a/Driveline/UI/DriveDetail/DriveDetailView.swift +++ b/Driveline/UI/DriveDetail/DriveDetailView.swift @@ -5,8 +5,6 @@ // Created by Damien Glancy on 30/05/2026. // -import CoreLocation -import MapKit import SwiftData import SwiftUI import TipKit @@ -24,7 +22,6 @@ struct DriveDetailView: View { private let renameDriveTip = EditDriveTip() @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme @Environment(SpotlightIndexingService.self) private var spotlightIndexingService @Environment(\.modelContext) private var modelContext @@ -67,22 +64,7 @@ struct DriveDetailView: View { .padding(14) } - ScrollView { - VStack(alignment: .leading, spacing: 14) { - driveHeader(presenter: presenter) - statTiles - endpointsCard(presenter: presenter) - DriveDetailWeatherCard(presenter: presenter) { driveState.loadWeatherAttribution() } - DriveDetailMetadataCard(presenter: presenter, maxSpeedMPS: driveState.maxSpeedMetresPerSecond, positionCount: driveState.positionCount) - shareDriveButton - Spacer() - weatherAttributionFooter - } - .padding(.horizontal, 16) - .padding(.bottom, 24) - } - .padding(.top, 20) - .contentMargins(.top, 0, for: .scrollContent) + DriveInfoPanel(state: driveState) } } .toolbar(.hidden, for: .navigationBar) @@ -131,223 +113,6 @@ struct DriveDetailView: View { } } - // MARK: - Private Views - - private func driveHeader(presenter: DriveDetailPresenter) -> some View { - VStack(alignment: .leading, spacing: 3) { - Text(presenter.name) - .font(.title.weight(.bold)) - .foregroundStyle(Color(.label)) - .lineLimit(2) - .minimumScaleFactor(0.1) - .dynamicTypeSize(.xSmall ... .accessibility1) - Text(presenter.dateString) - .font(.callout) - .foregroundStyle(.secondary) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - } - - private var statTiles: some View { - let stats = DriveStatsPresenter(drive: driveState.drive) - return HStack(spacing: 10) { - DriveStatTile( - icon: "ruler", - label: String(localized: "Distance", comment: "Stat tile label"), - value: stats.distanceValue, - unit: stats.distanceUnit - ) - DriveStatTile( - icon: "clock", - label: String(localized: "Duration", comment: "Stat tile label"), - value: stats.durationValue, - unit: stats.durationUnit - ) - DriveStatTile( - icon: "speedometer", - label: String(localized: "Avg Speed", comment: "Stat tile label"), - value: stats.avgSpeedValue, - unit: stats.avgSpeedUnit - ) - } - } - - private func endpointsCard(presenter: DriveDetailPresenter) -> some View { - VStack(spacing: 0) { - IconRow( - title: presenter.startPlace ?? String(localized: "Unknown", comment: "Unknown place name"), - subtitle: String(localized: "Departure", comment: "Endpoint row subtitle"), - trailing: presenter.departureTime - ) { - Circle() - .fill(Color.green) - .frame(width: 13, height: 13) - .overlay(Circle().stroke(Color(.systemBackground), lineWidth: 2)) - .shadow(color: .black.opacity(0.15), radius: 1) - } - - Divider().padding(.leading, 52) - - IconRow( - title: presenter.endPlace ?? String(localized: "Unknown", comment: "Unknown place name"), - subtitle: String(localized: "Arrival", comment: "Endpoint row subtitle"), - trailing: presenter.arrivalTime - ) { - Image(systemName: Icons.Drive.finishFlag) - .font(.body.weight(.medium)) - .foregroundStyle(.red) - .dynamicTypeSize(.xSmall ... .xxxLarge) - } - } - .cardBackground(cornerRadius: 16) - } - - @ViewBuilder - private var weatherAttributionFooter: some View { - if let legalURL = driveState.weatherAttributionLegalURL, - let lightMarkURL = driveState.weatherAttributionLightMarkURL, - let darkMarkURL = driveState.weatherAttributionDarkMarkURL { - VStack(spacing: 4) { - Link(destination: legalURL) { - AsyncImage(url: colorScheme == .dark ? darkMarkURL : lightMarkURL) { image in - image.resizable().scaledToFit() - } placeholder: { - EmptyView() - } - .frame(height: 14) - } - Link(String(localized: "Weather data provided by Apple Weather", comment: "Weather attribution footer link"), destination: legalURL) - .font(.caption2) - .foregroundStyle(Color(.secondaryLabel)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - } - - private var shareDriveButton: some View { - Menu { - Button { - Task { await driveState.share(.gpx) } - } label: { - Label(String(localized: "Share as GPX", comment: "Share drive as GPX"), systemImage: Icons.Options.gpxFile) - } - Button { - Task { await driveState.share(.png) } - } label: { - Label(String(localized: "Share as PNG", comment: "Share drive as PNG"), systemImage: Icons.Options.pngImage) - } - } label: { - Label(String(localized: "Share Drive", comment: "Share button"), systemImage: Icons.Options.sharing) - .font(.body.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .overlay(alignment: .trailing) { - if driveState.isPreparingExport { - ProgressView().padding(.trailing, 16) - } - } - } - .disabled(!driveState.canExport || driveState.isPreparingExport) - .cardBackground(cornerRadius: 16) - } -} - -// MARK: - DriveDetailWeatherCard - -private struct DriveDetailWeatherCard: View { - - let presenter: DriveDetailPresenter - let onLoadAttribution: () -> Void - - var body: some View { - if presenter.hasWeather { - VStack(spacing: 0) { - if let symbol = presenter.startWeatherSymbol, - let description = presenter.startWeatherDescription { - IconRow( - title: description, - subtitle: String(localized: "At Departure", comment: "Weather row subtitle"), - trailing: presenter.startWeatherTemperature - ) { - Image(systemName: symbol) - .symbolRenderingMode(.multicolor) - .font(.callout) - .frame(width: 24) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - } - - if let symbol = presenter.endWeatherSymbol, - let description = presenter.endWeatherDescription { - Divider().padding(.leading, 52) - IconRow( - title: description, - subtitle: String(localized: "At Arrival", comment: "Weather row subtitle"), - trailing: presenter.endWeatherTemperature - ) { - Image(systemName: symbol) - .symbolRenderingMode(.multicolor) - .font(.callout) - .frame(width: 24) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - } - } - .cardBackground(cornerRadius: 16) - .task { onLoadAttribution() } - } - } -} - -// MARK: - DriveDetailMetadataCard - -private struct DriveDetailMetadataCard: View { - - let presenter: DriveDetailPresenter - let maxSpeedMPS: CLLocationSpeed - let positionCount: Int - - var body: some View { - VStack(spacing: 0) { - if presenter.hasCategory { - IconRow(title: String(localized: "Category", comment: "Metadata row"), trailing: presenter.categoryDisplayName) { - Image(systemName: Icons.Stats.category) - .font(.callout) - .foregroundStyle(.secondary) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - - Divider().padding(.leading, 52) - } - - IconRow(title: String(localized: "Top Speed", comment: "Metadata row"), trailing: presenter.topSpeed(maxSpeedMPS: maxSpeedMPS)) { - Image(systemName: Icons.Stats.speed) - .font(.callout) - .foregroundStyle(.secondary) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - - Divider().padding(.leading, 52) - - IconRow(title: String(localized: "Track Points", comment: "Metadata row"), trailing: presenter.trackPoints(count: positionCount)) { - Image(systemName: Icons.Stats.location) - .font(.callout) - .foregroundStyle(.secondary) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - - Divider().padding(.leading, 52) - - IconRow(title: String(localized: "Started by", comment: "Metadata row"), trailing: presenter.triggerDisplayName) { - Image(systemName: Icons.Stats.gpsSignal) - .font(.callout) - .foregroundStyle(.secondary) - .dynamicTypeSize(.xSmall ... .accessibility1) - } - } - .cardBackground(cornerRadius: 16) - } } // MARK: - Preview diff --git a/Driveline/UI/DriveDetail/DriveInfoPanel.swift b/Driveline/UI/DriveDetail/DriveInfoPanel.swift new file mode 100644 index 0000000..be0eba5 --- /dev/null +++ b/Driveline/UI/DriveDetail/DriveInfoPanel.swift @@ -0,0 +1,37 @@ +// +// DriveInfoPanel.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import SwiftUI + +struct DriveInfoPanel: View { + + // MARK: - Properties + + let state: DriveDetailState + + // MARK: - Body + + var body: some View { + let presenter = DriveDetailPresenter(drive: state.drive) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + DriveHeaderCard(presenter: presenter) + DriveStatTilesRow(drive: state.drive) + DriveEndpointsCard(presenter: presenter) + DriveDetailWeatherCard(presenter: presenter) { state.loadWeatherAttribution() } + DriveDetailMetadataCard(presenter: presenter, maxSpeedMPS: state.maxSpeedMetresPerSecond, positionCount: state.positionCount) + ShareDriveButton(state: state) + Spacer() + WeatherAttributionFooter(state: state) + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .padding(.top, 20) + .contentMargins(.top, 0, for: .scrollContent) + } +} diff --git a/Driveline/UI/Home/DriveListContent.swift b/Driveline/UI/Home/DriveListContent.swift new file mode 100644 index 0000000..0b0e356 --- /dev/null +++ b/Driveline/UI/Home/DriveListContent.swift @@ -0,0 +1,121 @@ +// +// DriveListContent.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import SwiftData +import SwiftUI +import TipKit + +enum DriveListMode { + case pushNavigation + case selectionDriven(selectedID: Binding) +} + +struct DriveListContent: View { + + // MARK: - Properties + + let sections: [DriveSection] + @Bindable var managementState: DriveManagementState + let mode: DriveListMode + let recentDriveCount: Int + let activeStatsPresenter: HomeStatsPresenter + let statsScopeLabel: String + let isSearchActive: Bool + let onStatsToggle: () -> Void + var showAutomationBanner: Bool = false + var onShowAutomation: (() -> Void)? + + @Environment(\.modelContext) private var modelContext + @Environment(SpotlightIndexingService.self) private var spotlightIndexingService + + // MARK: - Body + + var body: some View { + List { + if recentDriveCount > 0 && !managementState.isSelectMode && !isSearchActive { + Section { + HomeStatsPanelView( + driveCount: activeStatsPresenter.driveCount, + distanceValue: activeStatsPresenter.distanceValue, + distanceUnit: activeStatsPresenter.distanceUnit, + scopeLabel: statsScopeLabel, + onTap: onStatsToggle + ) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .popoverTip(StatsPanelTip()) + } + .listSectionSpacing(8) + } + + if showAutomationBanner && !managementState.isSelectMode && !isSearchActive { + Section { + HomeAutomationSetupPanelView { onShowAutomation?() } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .listSectionSpacing(8) + } + + ForEach(sections) { section in + Section(section.title) { + ForEach(Array(section.rows.enumerated()), id: \.element.id) { index, row in + if managementState.isSelectMode { + Button { + managementState.toggleSelection(for: row.drive.id) + } label: { + DriveRowView( + drive: row.drive, + display: row.display, + style: .list(isSelected: managementState.selectedDriveIDs.contains(row.drive.id)) + ) + } + .buttonStyle(.plain) + } else { + driveRow(for: row, index: index) + } + } + .onDelete(perform: managementState.isSelectMode ? nil : { indexSet in + let drives = indexSet.map { section.rows[$0].drive } + DriveDeletion.delete(drives, in: modelContext, deindexing: spotlightIndexingService) + }) + } + } + + if managementState.isSelectMode { + Color.clear + .frame(height: 70) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .contentMargins(.top, 0, for: .scrollContent) + } + + // MARK: - Private + + @ViewBuilder + private func driveRow(for row: DriveRow, index: Int) -> some View { + switch mode { + case .pushNavigation: + NavigationLink(value: row.drive) { + DriveRowView(drive: row.drive, display: row.display) + } + .accessibilityIdentifier("Drive row \(index)") + case .selectionDriven(let selectedID): + Button { + selectedID.wrappedValue = row.drive.id + } label: { + DriveRowView(drive: row.drive, display: row.display) + } + .buttonStyle(.plain) + .accessibilityIdentifier("Drive row \(index)") + } + } +} diff --git a/Driveline/UI/Home/DriveManagementState.swift b/Driveline/UI/Home/DriveManagementState.swift new file mode 100644 index 0000000..0040dce --- /dev/null +++ b/Driveline/UI/Home/DriveManagementState.swift @@ -0,0 +1,62 @@ +// +// DriveManagementState.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import Foundation +import Observation +import SwiftData +import SwiftUI + +@MainActor +@Observable +final class DriveManagementState { + + // MARK: - Properties + + var isSelectMode: Bool = false + var selectedDriveIDs: Set = [] + var drivesToMerge: [Drive] = [] + var showingMergeSheet: Bool = false + var showingDeleteConfirmation: Bool = false + + // MARK: - Computed Properties + + var canMerge: Bool { selectedDriveIDs.count == 2 } + var canDelete: Bool { !selectedDriveIDs.isEmpty } + + // MARK: - Actions + + func enterSelectMode() { + isSelectMode = true + selectedDriveIDs = [] + } + + func exitSelectMode() { + isSelectMode = false + selectedDriveIDs = [] + } + + func toggleSelection(for id: UUID) { + if selectedDriveIDs.contains(id) { + selectedDriveIDs.remove(id) + } else { + selectedDriveIDs.insert(id) + } + } + + func triggerMerge(from sections: [DriveSection]) { + drivesToMerge = selectedDrives(from: sections).sorted { $0.startedAt < $1.startedAt } + showingMergeSheet = true + } + + func selectedDrives(from sections: [DriveSection]) -> [Drive] { + sections.flatMap(\.rows).map(\.drive).filter { selectedDriveIDs.contains($0.id) } + } + + func delete(_ drives: [Drive], in modelContext: ModelContext, deindexing spotlight: SpotlightIndexingService) { + DriveDeletion.delete(drives, in: modelContext, deindexing: spotlight) + } +} diff --git a/Driveline/UI/Home/HomeView.swift b/Driveline/UI/Home/HomeView.swift index 543af1d..b076e6d 100644 --- a/Driveline/UI/Home/HomeView.swift +++ b/Driveline/UI/Home/HomeView.swift @@ -27,11 +27,7 @@ struct HomeView: View { @State private var navigationPath: NavigationPath = NavigationPath() @State private var searchText: String = "" @State private var statsScope: StatsScope = .last30Days - @State private var isSelectMode: Bool = false - @State private var selectedDriveIDs: Set = [] - @State private var drivesToMerge: [Drive] = [] - @State private var showingDeleteConfirmation: Bool = false - @State private var showingMergeSheet: Bool = false + @State private var managementState = DriveManagementState() @State private var showingLocationPrimer: Bool = false @State private var showingAutomationSetup: Bool = false @State private var hasSeenAutomationSetup: Bool = UserPreferences().hasSeenAutomationSetup @@ -48,8 +44,6 @@ struct HomeView: View { private var activeStatsPresenter: HomeStatsPresenter { HomeStatsPresenter(stats: activeStats) } private var isSearchActive: Bool { !searchText.isEmpty } - private var canMerge: Bool { selectedDriveIDs.count == 2 } - private var canDelete: Bool { !selectedDriveIDs.isEmpty } // MARK: - Body @@ -61,10 +55,10 @@ struct HomeView: View { .searchDictationBehavior(.inline(activation: .onSelect)) .toolbar { HomeToolbar( - isSelectMode: isSelectMode, + isSelectMode: managementState.isSelectMode, isSectionsEmpty: sections.isEmpty, - onExitSelectMode: exitSelectMode, - onEnterSelectMode: enterSelectMode, + onExitSelectMode: { managementState.exitSelectMode() }, + onEnterSelectMode: { managementState.enterSelectMode() }, onOpenSettings: { if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) @@ -74,7 +68,7 @@ struct HomeView: View { ) } .onChange(of: driveService.isRecording, initial: true) { _, isRecording in - if isRecording { exitSelectMode() } + if isRecording { managementState.exitSelectMode() } StatsPanelTip.isRecording = isRecording RecordButtonTip.isRecording = isRecording } @@ -100,24 +94,24 @@ struct HomeView: View { )) .alert( String(localized: "Delete Drives", comment: "Delete confirmation alert title"), - isPresented: $showingDeleteConfirmation + isPresented: $managementState.showingDeleteConfirmation ) { Button.delete { - let selected = selectedDrives() - exitSelectMode() - deleteDrives(selected) + let selected = managementState.selectedDrives(from: sections) + managementState.exitSelectMode() + managementState.delete(selected, in: modelContext, deindexing: spotlightIndexingService) } Button.cancel() } message: { - Text(HomePresenter.deleteConfirmationMessage(selectedDriveIDs.count)) + Text(HomePresenter.deleteConfirmationMessage(managementState.selectedDriveIDs.count)) } - .sheet(isPresented: $showingMergeSheet) { - if drivesToMerge.count == 2 { + .sheet(isPresented: $managementState.showingMergeSheet) { + if managementState.drivesToMerge.count == 2 { MergeDrivesView( - drives: drivesToMerge, + drives: managementState.drivesToMerge, modelContainer: modelContext.container, spotlight: spotlightIndexingService, - onMerged: { exitSelectMode() } + onMerged: { managementState.exitSelectMode() } ) } } @@ -153,78 +147,31 @@ struct HomeView: View { private var driveList: some View { ZStack(alignment: .bottom) { - List { - if recentStats.driveCount > 0 && !isSelectMode && !isSearchActive { - Section { - HomeStatsPanelView( - driveCount: activeStatsPresenter.driveCount, - distanceValue: activeStatsPresenter.distanceValue, - distanceUnit: activeStatsPresenter.distanceUnit, - scopeLabel: HomePresenter.statsScopeLabel(statsScope), - onTap: { statsScope = statsScope == .last30Days ? .allTime : .last30Days } - ) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .popoverTip(StatsPanelTip()) - } - .listSectionSpacing(8) - } - - if !hasSeenAutomationSetup && !isSelectMode && !isSearchActive - && (!Driveline.isUITesting() || Driveline.isOnboardingTesting()) { - Section { - HomeAutomationSetupPanelView { showingAutomationSetup = true } - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - .listSectionSpacing(8) - } - - ForEach(sections) { section in - Section(section.title) { - ForEach(Array(section.rows.enumerated()), id: \.element.id) { index, row in - if isSelectMode { - Button { - toggleSelection(for: row.drive.id) - } label: { - DriveRowView(drive: row.drive, display: row.display, style: .list(isSelected: selectedDriveIDs.contains(row.drive.id))) - } - .buttonStyle(.plain) - } else { - NavigationLink(value: row.drive) { - DriveRowView(drive: row.drive, display: row.display) - }.accessibilityIdentifier("Drive row \(index)") - } - } - .onDelete(perform: isSelectMode ? nil : { indexSet in - deleteDrives(at: indexSet, in: section) - }) - } - } - - if isSelectMode { - Color.clear - .frame(height: 70) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - } - .contentMargins(.top, 0, for: .scrollContent) + DriveListContent( + sections: sections, + managementState: managementState, + mode: .pushNavigation, + recentDriveCount: recentStats.driveCount, + activeStatsPresenter: activeStatsPresenter, + statsScopeLabel: HomePresenter.statsScopeLabel(statsScope), + isSearchActive: isSearchActive, + onStatsToggle: { statsScope = statsScope == .last30Days ? .allTime : .last30Days }, + showAutomationBanner: !hasSeenAutomationSetup && (!Driveline.isUITesting() || Driveline.isOnboardingTesting()), + onShowAutomation: { showingAutomationSetup = true } + ) .navigationDestination(for: Drive.self) { drive in DriveDetailView(drive: drive, modelContainer: modelContext.container) } - if isSelectMode { + if managementState.isSelectMode { SelectionToolbar( - canMerge: canMerge, - canDelete: canDelete, - selectionCountText: HomePresenter.selectionCountText(selectedDriveIDs.count) + canMerge: managementState.canMerge, + canDelete: managementState.canDelete, + selectionCountText: HomePresenter.selectionCountText(managementState.selectedDriveIDs.count) ) { - triggerMerge() + managementState.triggerMerge(from: sections) } onDelete: { - showingDeleteConfirmation = true + managementState.showingDeleteConfirmation = true } } } @@ -248,41 +195,6 @@ struct HomeView: View { driveService.startDrive(trigger: trigger) } - private func enterSelectMode() { - isSelectMode = true - selectedDriveIDs = [] - } - - private func exitSelectMode() { - isSelectMode = false - selectedDriveIDs = [] - } - - private func triggerMerge() { - drivesToMerge = selectedDrives().sorted { $0.startedAt < $1.startedAt } - showingMergeSheet = true - } - - private func toggleSelection(for id: UUID) { - if selectedDriveIDs.contains(id) { - selectedDriveIDs.remove(id) - } else { - selectedDriveIDs.insert(id) - } - } - - private func selectedDrives() -> [Drive] { - sections.flatMap(\.rows).map(\.drive).filter { selectedDriveIDs.contains($0.id) } - } - - private func deleteDrives(_ drives: [Drive]) { - DriveDeletion.delete(drives, in: modelContext, deindexing: spotlightIndexingService) - } - - private func deleteDrives(at indexSet: IndexSet, in section: DriveSection) { - deleteDrives(indexSet.map { section.rows[$0].drive }) - } - private func openDrive(fromSpotlightIdentifier identifier: String) { guard let uuid = UUID(uuidString: identifier), let drive = drives.first(where: { $0.id == uuid }) else { return } diff --git a/Driveline/UI/Root/RootView.swift b/Driveline/UI/Root/RootView.swift new file mode 100644 index 0000000..25d3f5b --- /dev/null +++ b/Driveline/UI/Root/RootView.swift @@ -0,0 +1,41 @@ +// +// RootView.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import SwiftUI +import UIKit + +struct RootView: View { + + // MARK: - Properties + + @Binding var isOnboardingPresented: Bool + + @Environment(SpotlightIndexingService.self) private var spotlightIndexingService + + // MARK: - Body + + var body: some View { + if UIDevice.current.userInterfaceIdiom == .pad { + DrivesSplitView() + } else { + HomeView() + .fullScreenCover(isPresented: $isOnboardingPresented) { + OnboardingWelcomeView { + var prefs = UserPreferences() + prefs.setHasSeenWelcome(true) + isOnboardingPresented = false + } + .interactiveDismissDisabled() + } + .onChange(of: isOnboardingPresented, initial: true) { _, isPresented in + RecordButtonTip.isOnboardingPresented = isPresented + StatsPanelTip.isOnboardingPresented = isPresented + EditDriveTip.isOnboardingPresented = isPresented + } + } + } +} diff --git a/Driveline/UI/iPad/DriveViewerPlaceholderView.swift b/Driveline/UI/iPad/DriveViewerPlaceholderView.swift new file mode 100644 index 0000000..309b2df --- /dev/null +++ b/Driveline/UI/iPad/DriveViewerPlaceholderView.swift @@ -0,0 +1,18 @@ +// +// DriveViewerPlaceholderView.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import SwiftUI + +struct DriveViewerPlaceholderView: View { + + var body: some View { + ContentUnavailableView( + String(localized: "Select a Drive", comment: "iPad placeholder when no drive is selected"), + systemImage: Icons.Widgets.car + ) + } +} diff --git a/Driveline/UI/iPad/DriveViewerView.swift b/Driveline/UI/iPad/DriveViewerView.swift new file mode 100644 index 0000000..efe63aa --- /dev/null +++ b/Driveline/UI/iPad/DriveViewerView.swift @@ -0,0 +1,160 @@ +// +// DriveViewerView.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import MapKit +import SwiftData +import SwiftUI + +struct DriveViewerView: View { + + // MARK: - Properties + + @State private var driveState: DriveDetailState + @State private var isInspectorPresented: Bool = true + @State private var showingFullScreenMap: Bool = false + @State private var showingDeleteConfirmation: Bool = false + @State private var showingEditDrive: Bool = false + @State private var showingMoreMenu: Bool = false + + @Environment(SpotlightIndexingService.self) private var spotlightIndexingService + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + // MARK: - Lifecycle + + init(drive: Drive, modelContainer: ModelContainer) { + _driveState = State(initialValue: DriveDetailState(drive: drive, modelContainer: modelContainer)) + } + + // MARK: - Body + + var body: some View { + NavigationStack { + Map(position: $driveState.cameraPosition, interactionModes: .all) { + DriveMapContent(segments: driveState.coordinateSegments) + } + .mapStyle(.standard(emphasis: .muted)) + .ignoresSafeArea() + .accessibilityLabel(Text(DriveDetailPresenter(drive: driveState.drive).routeAccessibilityLabel)) + .task { await driveState.loadRoute() } + .toolbar { + DriveViewerToolbar( + isInspectorPresented: $isInspectorPresented, + showingFullScreenMap: $showingFullScreenMap, + showingMoreMenu: $showingMoreMenu, + driveState: driveState + ) + } + .inspector(isPresented: $isInspectorPresented) { + DriveInfoPanel(state: driveState) + } + .navigationDestination(isPresented: $showingFullScreenMap) { + FullScreenMapView( + drive: driveState.drive, + modelContainer: driveState.drive.modelContext?.container ?? modelContext.container + ) + } + .alert( + String(localized: "Delete Drive", comment: "Delete confirmation alert title"), + isPresented: $showingDeleteConfirmation + ) { + Button.delete { + DriveDeletion.delete([driveState.drive], in: modelContext, deindexing: spotlightIndexingService) + dismiss() + } + Button.cancel() + } message: { + Text(String(localized: "This drive and all its data will be permanently deleted.", comment: "Delete drive confirmation message")) + } + .sheet(isPresented: $showingEditDrive) { + EditDriveView(drive: driveState.drive) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + String(localized: "Drive Options", comment: "More menu title"), + isPresented: $showingMoreMenu + ) { + Button(String(localized: "Edit Drive Details", comment: "More menu action")) { + showingEditDrive = true + } + Button(String(localized: "Delete Drive", comment: "More menu action"), role: .destructive) { + showingDeleteConfirmation = true + } + Button.cancel() + } + } + .sheet(item: $driveState.shareItem) { item in + ActivityView(activityItems: [item.url]) + } + .alert( + String(localized: "Couldn't Share Drive", comment: "Export failure alert title"), + isPresented: $driveState.showingExportError + ) { + Button(String(localized: "OK", comment: "Dismiss export error alert"), role: .cancel) { } + } message: { + Text(driveState.exportErrorMessage ?? "") + } + } +} + +// MARK: - DriveViewerToolbar + +private struct DriveViewerToolbar: ToolbarContent { + + @Binding var isInspectorPresented: Bool + @Binding var showingFullScreenMap: Bool + @Binding var showingMoreMenu: Bool + let driveState: DriveDetailState + + var body: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + Task { await driveState.share(.gpx) } + } label: { + Label(String(localized: "Share as GPX", comment: "Share drive as GPX"), systemImage: Icons.Options.gpxFile) + } + Button { + Task { await driveState.share(.png) } + } label: { + Label(String(localized: "Share as PNG", comment: "Share drive as PNG"), systemImage: Icons.Options.pngImage) + } + } label: { + Image(systemName: Icons.Options.sharing) + } + .accessibilityLabel(String(localized: "Share Drive", comment: "Share button accessibility label")) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + showingMoreMenu = true + } label: { + Image(systemName: Icons.Options.ellipsis) + } + .accessibilityLabel(String(localized: "More options", comment: "More options button accessibility label")) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + showingFullScreenMap = true + } label: { + Image(systemName: Icons.Options.viewfinder) + } + .accessibilityLabel(String(localized: "Full screen map", comment: "Full screen map button accessibility label")) + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + isInspectorPresented.toggle() + } label: { + Image(systemName: "sidebar.right") + } + .accessibilityLabel(String(localized: "Toggle drive info panel", comment: "Inspector toggle button accessibility label")) + } + } +} diff --git a/Driveline/UI/iPad/DrivesSplitView.swift b/Driveline/UI/iPad/DrivesSplitView.swift new file mode 100644 index 0000000..5fa773c --- /dev/null +++ b/Driveline/UI/iPad/DrivesSplitView.swift @@ -0,0 +1,139 @@ +// +// DrivesSplitView.swift +// Driveline +// +// Created by Damien Glancy on 27/06/2026. +// + +import CoreSpotlight +import SwiftData +import SwiftUI + +struct DrivesSplitView: View { + + // MARK: - Properties + + @Query(sort: \Drive.startedAt, order: .reverse) + private var drives: [Drive] + + @State private var selectedDriveID: UUID? + @State private var searchText: String = "" + @State private var statsScope: StatsScope = .last30Days + @State private var managementState = DriveManagementState() + + @Environment(SpotlightIndexingService.self) private var spotlightIndexingService + @Environment(\.modelContext) private var modelContext + + // MARK: - Computed Properties + + private var sections: [DriveSection] { + DriveSectionBuilder.sections(from: drives, searchText: searchText) + } + + private var selectedDrive: Drive? { + guard let id = selectedDriveID else { return nil } + return drives.first { $0.id == id } + } + + private var recentStats: DriveStats { DriveStats.recent(from: drives) } + private var allTimeStats: DriveStats { DriveStats.allTime(from: drives) } + private var activeStats: DriveStats { statsScope == .last30Days ? recentStats : allTimeStats } + private var activeStatsPresenter: HomeStatsPresenter { HomeStatsPresenter(stats: activeStats) } + + private var isSearchActive: Bool { !searchText.isEmpty } + + // MARK: - Body + + var body: some View { + NavigationSplitView { + ZStack(alignment: .bottom) { + DriveListContent( + sections: sections, + managementState: managementState, + mode: .selectionDriven(selectedID: $selectedDriveID), + recentDriveCount: recentStats.driveCount, + activeStatsPresenter: activeStatsPresenter, + statsScopeLabel: HomePresenter.statsScopeLabel(statsScope), + isSearchActive: isSearchActive, + onStatsToggle: { statsScope = statsScope == .last30Days ? .allTime : .last30Days } + ) + .searchable(text: $searchText, prompt: String(localized: "Search", comment: "Search field prompt")) + .navigationTitle(String(localized: "Drives", comment: "Navigation title for drives list")) + .toolbar { sidebarToolbar } + .alert( + String(localized: "Delete Drives", comment: "Delete confirmation alert title"), + isPresented: $managementState.showingDeleteConfirmation + ) { + Button.delete { + let selected = managementState.selectedDrives(from: sections) + if let id = selectedDriveID, selected.contains(where: { $0.id == id }) { + selectedDriveID = nil + } + managementState.exitSelectMode() + managementState.delete(selected, in: modelContext, deindexing: spotlightIndexingService) + } + Button.cancel() + } message: { + Text(HomePresenter.deleteConfirmationMessage(managementState.selectedDriveIDs.count)) + } + .sheet(isPresented: $managementState.showingMergeSheet) { + if managementState.drivesToMerge.count == 2 { + MergeDrivesView( + drives: managementState.drivesToMerge, + modelContainer: modelContext.container, + spotlight: spotlightIndexingService, + onMerged: { managementState.exitSelectMode() } + ) + } + } + + if managementState.isSelectMode { + SelectionToolbar( + canMerge: managementState.canMerge, + canDelete: managementState.canDelete, + selectionCountText: HomePresenter.selectionCountText(managementState.selectedDriveIDs.count) + ) { + managementState.triggerMerge(from: sections) + } onDelete: { + managementState.showingDeleteConfirmation = true + } + } + } + } detail: { + if let drive = selectedDrive { + DriveViewerView(drive: drive, modelContainer: modelContext.container) + .id(drive.id) + } else { + DriveViewerPlaceholderView() + } + } + .onContinueUserActivity(CSSearchableItemActionType) { activity in + guard let identifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String, + let uuid = UUID(uuidString: identifier) else { return } + selectedDriveID = uuid + } + } + + // MARK: - Private + + @ToolbarContentBuilder + private var sidebarToolbar: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + if managementState.isSelectMode { + Button.cancel { managementState.exitSelectMode() } + } + } + + if !managementState.isSelectMode { + ToolbarItem(placement: .topBarTrailing) { + Button( + String(localized: "Select Drives", comment: "Menu item to enter multiselect mode"), + systemImage: "checkmark.circle" + ) { + managementState.enterSelectMode() + } + .disabled(sections.isEmpty) + } + } + } +} diff --git a/DrivelineTests/AppLifecycleTests/RecordingAvailabilityTests.swift b/DrivelineTests/AppLifecycleTests/RecordingAvailabilityTests.swift new file mode 100644 index 0000000..024e8df --- /dev/null +++ b/DrivelineTests/AppLifecycleTests/RecordingAvailabilityTests.swift @@ -0,0 +1,30 @@ +// +// RecordingAvailabilityTests.swift +// DrivelineTests +// +// Created by Damien Glancy on 27/06/2026. +// + +@testable import Driveline +import Testing +import UIKit + +@Suite("RecordingAvailability") +@MainActor +struct RecordingAvailabilityTests { + + @Test + func padIsNotSupported() { + #expect(!RecordingAvailability.isSupported(.pad)) + } + + @Test + func phoneIsSupported() { + #expect(RecordingAvailability.isSupported(.phone)) + } + + @Test + func unspecifiedIsSupported() { + #expect(RecordingAvailability.isSupported(.unspecified)) + } +} diff --git a/DrivelineTests/UITests/DriveManagementStateTests.swift b/DrivelineTests/UITests/DriveManagementStateTests.swift new file mode 100644 index 0000000..3f1b3a7 --- /dev/null +++ b/DrivelineTests/UITests/DriveManagementStateTests.swift @@ -0,0 +1,158 @@ +// +// DriveManagementStateTests.swift +// DrivelineTests +// +// Created by Damien Glancy on 27/06/2026. +// + +@testable import Driveline +import Foundation +import Testing + +@Suite("DriveManagementState") +@MainActor +struct DriveManagementStateTests { + + // MARK: - enterSelectMode + + @Test + func enterSelectModeSetsIsSelectMode() { + let state = DriveManagementState() + state.enterSelectMode() + #expect(state.isSelectMode == true) + } + + @Test + func enterSelectModeClearsSelectedIDs() { + let state = DriveManagementState() + state.selectedDriveIDs.insert(UUID()) + state.enterSelectMode() + #expect(state.selectedDriveIDs.isEmpty) + } + + // MARK: - exitSelectMode + + @Test + func exitSelectModeClearsIsSelectMode() { + let state = DriveManagementState() + state.enterSelectMode() + state.exitSelectMode() + #expect(state.isSelectMode == false) + } + + @Test + func exitSelectModeClearsSelectedIDs() { + let state = DriveManagementState() + let id = UUID() + state.selectedDriveIDs.insert(id) + state.exitSelectMode() + #expect(state.selectedDriveIDs.isEmpty) + } + + // MARK: - toggleSelection + + @Test + func toggleSelectionAddsID() { + let state = DriveManagementState() + let id = UUID() + state.toggleSelection(for: id) + #expect(state.selectedDriveIDs.contains(id)) + } + + @Test + func toggleSelectionRemovesAlreadySelectedID() { + let state = DriveManagementState() + let id = UUID() + state.toggleSelection(for: id) + state.toggleSelection(for: id) + #expect(!state.selectedDriveIDs.contains(id)) + } + + // MARK: - canMerge / canDelete + + @Test + func canMergeRequiresExactlyTwoSelected() { + let state = DriveManagementState() + #expect(!state.canMerge) + state.toggleSelection(for: UUID()) + #expect(!state.canMerge) + state.toggleSelection(for: UUID()) + #expect(state.canMerge) + state.toggleSelection(for: UUID()) + #expect(!state.canMerge) + } + + @Test + func canDeleteRequiresAtLeastOneSelected() { + let state = DriveManagementState() + #expect(!state.canDelete) + state.toggleSelection(for: UUID()) + #expect(state.canDelete) + } + + // MARK: - selectedDrives + + @Test + func selectedDrivesReturnsOnlySelectedDrives() { + let state = DriveManagementState() + let drive1 = makeDrive() + let drive2 = makeDrive() + let drive3 = makeDrive() + let sections = [DriveSection(title: "Today", rows: [DriveRow(drive: drive1), DriveRow(drive: drive2), DriveRow(drive: drive3)])] + + state.toggleSelection(for: drive1.id) + state.toggleSelection(for: drive3.id) + + let selected = state.selectedDrives(from: sections) + #expect(selected.count == 2) + #expect(selected.contains { $0.id == drive1.id }) + #expect(selected.contains { $0.id == drive3.id }) + } + + @Test + func selectedDrivesReturnsEmptyWhenNoneSelected() { + let state = DriveManagementState() + let sections = [DriveSection(title: "Today", rows: [DriveRow(drive: makeDrive())])] + #expect(state.selectedDrives(from: sections).isEmpty) + } + + // MARK: - triggerMerge + + @Test + func triggerMergeSortsDrivesChronologically() { + let state = DriveManagementState() + let older = makeDrive(startedAt: Date(timeIntervalSinceReferenceDate: 1000)) + let newer = makeDrive(startedAt: Date(timeIntervalSinceReferenceDate: 2000)) + let sections = [DriveSection(title: "Today", rows: [DriveRow(drive: newer), DriveRow(drive: older)])] + + state.toggleSelection(for: older.id) + state.toggleSelection(for: newer.id) + state.triggerMerge(from: sections) + + #expect(state.drivesToMerge.count == 2) + #expect(state.drivesToMerge[0].id == older.id) + #expect(state.drivesToMerge[1].id == newer.id) + } + + @Test + func triggerMergeSetsShowingMergeSheet() { + let state = DriveManagementState() + let drive1 = makeDrive(startedAt: Date(timeIntervalSinceReferenceDate: 1000)) + let drive2 = makeDrive(startedAt: Date(timeIntervalSinceReferenceDate: 2000)) + let sections = [DriveSection(title: "Today", rows: [DriveRow(drive: drive1), DriveRow(drive: drive2)])] + + state.toggleSelection(for: drive1.id) + state.toggleSelection(for: drive2.id) + state.triggerMerge(from: sections) + + #expect(state.showingMergeSheet == true) + } +} + +// MARK: - Helpers + +private func makeDrive(startedAt: Date = .now) -> Drive { + let drive = Drive() + drive.startedAt = startedAt + return drive +} From 6c79819a7d7e665029548438076aa0beb03652a9 Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 11:31:23 +0100 Subject: [PATCH 2/8] #147 slightly better on-boarding --- Driveline.xcodeproj/project.pbxproj | 10 +- .../DriveWidgetExtensionExtension.xcscheme | 2 +- .../xcshareddata/xcschemes/Driveline.xcscheme | 2 +- .../xcschemes/MLTrainingDataPrepTool.xcscheme | 2 +- Driveline/AppLifecycle/Localizable.xcstrings | 6 ++ Driveline/UI/iPad/DrivesSplitView.swift | 91 +++++++++++-------- Driveline/UI/iPad/IPadEmptyStateView.swift | 22 +++++ 7 files changed, 95 insertions(+), 40 deletions(-) create mode 100644 Driveline/UI/iPad/IPadEmptyStateView.swift diff --git a/Driveline.xcodeproj/project.pbxproj b/Driveline.xcodeproj/project.pbxproj index bf4ca1c..1e8ed29 100644 --- a/Driveline.xcodeproj/project.pbxproj +++ b/Driveline.xcodeproj/project.pbxproj @@ -524,7 +524,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2650; - LastUpgradeCheck = 2650; + LastUpgradeCheck = 2660; TargetAttributes = { 6D47A50F2FCAD20600E6C7B7 = { CreatedOnToolsVersion = 26.5; @@ -734,6 +734,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -788,6 +789,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -803,6 +805,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -850,6 +853,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1179,6 +1183,7 @@ buildSettings = { CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4U6275HKV3; ENABLE_USER_SCRIPT_SANDBOXING = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; @@ -1194,6 +1199,7 @@ buildSettings = { CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4U6275HKV3; ENABLE_USER_SCRIPT_SANDBOXING = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; @@ -1209,6 +1215,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4U6275HKV3; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; @@ -1225,6 +1232,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4U6275HKV3; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; diff --git a/Driveline.xcodeproj/xcshareddata/xcschemes/DriveWidgetExtensionExtension.xcscheme b/Driveline.xcodeproj/xcshareddata/xcschemes/DriveWidgetExtensionExtension.xcscheme index 1167547..bc18730 100644 --- a/Driveline.xcodeproj/xcshareddata/xcschemes/DriveWidgetExtensionExtension.xcscheme +++ b/Driveline.xcodeproj/xcshareddata/xcschemes/DriveWidgetExtensionExtension.xcscheme @@ -1,6 +1,6 @@ Date: Sat, 27 Jun 2026 13:07:55 +0100 Subject: [PATCH 3/8] #147 UI Tweaks to ipad version --- Driveline/AppLifecycle/Localizable.xcstrings | 4 +- Driveline/UI/iPad/DriveViewerView.swift | 141 ++++++++++++++----- Driveline/UI/iPad/DrivesSplitView.swift | 5 +- 3 files changed, 111 insertions(+), 39 deletions(-) diff --git a/Driveline/AppLifecycle/Localizable.xcstrings b/Driveline/AppLifecycle/Localizable.xcstrings index f2d5337..3792648 100644 --- a/Driveline/AppLifecycle/Localizable.xcstrings +++ b/Driveline/AppLifecycle/Localizable.xcstrings @@ -2539,7 +2539,7 @@ } }, "Full screen map" : { - "comment" : "Accessibility label for the button that opens the full screen map on the drive detail screen\nFull screen map button accessibility label", + "comment" : "Accessibility label for the button that opens the full screen map on the drive detail screen", "localizations" : { "de" : { "stringUnit" : { @@ -5070,7 +5070,7 @@ } }, "Toggle drive info panel" : { - "comment" : "Inspector toggle button accessibility label" + "comment" : "Info panel toggle button accessibility label" }, "Top Speed" : { "comment" : "Metadata row", diff --git a/Driveline/UI/iPad/DriveViewerView.swift b/Driveline/UI/iPad/DriveViewerView.swift index efe63aa..0d71fce 100644 --- a/Driveline/UI/iPad/DriveViewerView.swift +++ b/Driveline/UI/iPad/DriveViewerView.swift @@ -13,9 +13,10 @@ struct DriveViewerView: View { // MARK: - Properties + @Binding private var columnVisibility: NavigationSplitViewVisibility + @State private var driveState: DriveDetailState @State private var isInspectorPresented: Bool = true - @State private var showingFullScreenMap: Bool = false @State private var showingDeleteConfirmation: Bool = false @State private var showingEditDrive: Bool = false @State private var showingMoreMenu: Bool = false @@ -26,38 +27,37 @@ struct DriveViewerView: View { // MARK: - Lifecycle - init(drive: Drive, modelContainer: ModelContainer) { + init(drive: Drive, modelContainer: ModelContainer, columnVisibility: Binding) { _driveState = State(initialValue: DriveDetailState(drive: drive, modelContainer: modelContainer)) + _columnVisibility = columnVisibility } // MARK: - Body var body: some View { NavigationStack { - Map(position: $driveState.cameraPosition, interactionModes: .all) { - DriveMapContent(segments: driveState.coordinateSegments) + ZStack(alignment: .bottom) { + Map(position: $driveState.cameraPosition, interactionModes: .all) { + DriveMapContent(segments: driveState.coordinateSegments) + } + .mapStyle(.standard(emphasis: .muted)) + .ignoresSafeArea() + .accessibilityLabel(Text(DriveDetailPresenter(drive: driveState.drive).routeAccessibilityLabel)) + .task { await driveState.loadRoute() } + + if isInspectorPresented { + DriveBottomPanel(state: driveState, isPresented: $isInspectorPresented) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } - .mapStyle(.standard(emphasis: .muted)) - .ignoresSafeArea() - .accessibilityLabel(Text(DriveDetailPresenter(drive: driveState.drive).routeAccessibilityLabel)) - .task { await driveState.loadRoute() } + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: isInspectorPresented) .toolbar { DriveViewerToolbar( isInspectorPresented: $isInspectorPresented, - showingFullScreenMap: $showingFullScreenMap, showingMoreMenu: $showingMoreMenu, driveState: driveState ) } - .inspector(isPresented: $isInspectorPresented) { - DriveInfoPanel(state: driveState) - } - .navigationDestination(isPresented: $showingFullScreenMap) { - FullScreenMapView( - drive: driveState.drive, - modelContainer: driveState.drive.modelContext?.container ?? modelContext.container - ) - } .alert( String(localized: "Delete Drive", comment: "Delete confirmation alert title"), isPresented: $showingDeleteConfirmation @@ -70,11 +70,6 @@ struct DriveViewerView: View { } message: { Text(String(localized: "This drive and all its data will be permanently deleted.", comment: "Delete drive confirmation message")) } - .sheet(isPresented: $showingEditDrive) { - EditDriveView(drive: driveState.drive) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } .confirmationDialog( String(localized: "Drive Options", comment: "More menu title"), isPresented: $showingMoreMenu @@ -91,6 +86,11 @@ struct DriveViewerView: View { .sheet(item: $driveState.shareItem) { item in ActivityView(activityItems: [item.url]) } + .sheet(isPresented: $showingEditDrive) { + EditDriveView(drive: driveState.drive) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } .alert( String(localized: "Couldn't Share Drive", comment: "Export failure alert title"), isPresented: $driveState.showingExportError @@ -102,12 +102,92 @@ struct DriveViewerView: View { } } +// MARK: - DriveBottomPanel + +private struct DriveBottomPanel: View { + + // MARK: - Properties + + let state: DriveDetailState + @Binding var isPresented: Bool + + @State private var detent: Detent = .medium + @State private var dragOffset: CGFloat = 0 + + private enum Detent { case collapsed, medium, expanded } + + // MARK: - Body + + var body: some View { + GeometryReader { geo in + let base = targetHeight(for: detent, in: geo.size.height) + let height = max(60, base - dragOffset) + + VStack(spacing: 0) { + handle + DriveInfoPanel(state: state) + } + .frame(maxWidth: .infinity) + .frame(height: height, alignment: .top) + .background(.regularMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + + // MARK: - Private + + private func targetHeight(for detent: Detent, in availableHeight: CGFloat) -> CGFloat { + switch detent { + case .collapsed: return 80 + case .medium: return availableHeight * 0.45 + case .expanded: return availableHeight * 0.88 + } + } + + private var handle: some View { + Capsule() + .fill(Color.secondary.opacity(0.5)) + .frame(width: 36, height: 5) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 5) + .onChanged { value in + dragOffset = value.translation.height + } + .onEnded { value in + let predicted = value.predictedEndTranslation.height + let prior = detent + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + dragOffset = 0 + if predicted > 120 { + switch prior { + case .expanded: detent = .medium + case .medium: detent = .collapsed + case .collapsed: break + } + } else if predicted < -120 { + switch prior { + case .collapsed: detent = .medium + case .medium: detent = .expanded + case .expanded: break + } + } + } + if predicted > 120, prior == .collapsed { + isPresented = false + } + } + ) + } +} + // MARK: - DriveViewerToolbar private struct DriveViewerToolbar: ToolbarContent { @Binding var isInspectorPresented: Bool - @Binding var showingFullScreenMap: Bool @Binding var showingMoreMenu: Bool let driveState: DriveDetailState @@ -139,22 +219,13 @@ private struct DriveViewerToolbar: ToolbarContent { .accessibilityLabel(String(localized: "More options", comment: "More options button accessibility label")) } - ToolbarItem(placement: .topBarTrailing) { - Button { - showingFullScreenMap = true - } label: { - Image(systemName: Icons.Options.viewfinder) - } - .accessibilityLabel(String(localized: "Full screen map", comment: "Full screen map button accessibility label")) - } - ToolbarItem(placement: .topBarTrailing) { Button { isInspectorPresented.toggle() } label: { - Image(systemName: "sidebar.right") + Image(systemName: "info.circle") } - .accessibilityLabel(String(localized: "Toggle drive info panel", comment: "Inspector toggle button accessibility label")) + .accessibilityLabel(String(localized: "Toggle drive info panel", comment: "Info panel toggle button accessibility label")) } } } diff --git a/Driveline/UI/iPad/DrivesSplitView.swift b/Driveline/UI/iPad/DrivesSplitView.swift index 9a96219..85cbbfe 100644 --- a/Driveline/UI/iPad/DrivesSplitView.swift +++ b/Driveline/UI/iPad/DrivesSplitView.swift @@ -20,6 +20,7 @@ struct DrivesSplitView: View { @State private var searchText: String = "" @State private var statsScope: StatsScope = .last30Days @State private var managementState = DriveManagementState() + @State private var columnVisibility: NavigationSplitViewVisibility = .all @Environment(SpotlightIndexingService.self) private var spotlightIndexingService @Environment(\.modelContext) private var modelContext @@ -55,7 +56,7 @@ struct DrivesSplitView: View { // MARK: - Private Views private var splitView: some View { - NavigationSplitView { + NavigationSplitView(columnVisibility: $columnVisibility) { ZStack(alignment: .bottom) { sidebarContent .searchable(text: $searchText, prompt: String(localized: "Search", comment: "Search field prompt")) @@ -102,7 +103,7 @@ struct DrivesSplitView: View { } } detail: { if let drive = selectedDrive { - DriveViewerView(drive: drive, modelContainer: modelContext.container) + DriveViewerView(drive: drive, modelContainer: modelContext.container, columnVisibility: $columnVisibility) .id(drive.id) } else { DriveViewerPlaceholderView() From 8c870cd581668705b8b64e6a882d1127837d1288 Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 13:24:51 +0100 Subject: [PATCH 4/8] #147 Remembering info panel preferences --- Driveline/UI/iPad/DriveViewerView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Driveline/UI/iPad/DriveViewerView.swift b/Driveline/UI/iPad/DriveViewerView.swift index 0d71fce..b1dd9f9 100644 --- a/Driveline/UI/iPad/DriveViewerView.swift +++ b/Driveline/UI/iPad/DriveViewerView.swift @@ -16,7 +16,7 @@ struct DriveViewerView: View { @Binding private var columnVisibility: NavigationSplitViewVisibility @State private var driveState: DriveDetailState - @State private var isInspectorPresented: Bool = true + @AppStorage("iPadInfoPanelVisible") private var isInspectorPresented: Bool = true @State private var showingDeleteConfirmation: Bool = false @State private var showingEditDrive: Bool = false @State private var showingMoreMenu: Bool = false @@ -111,10 +111,10 @@ private struct DriveBottomPanel: View { let state: DriveDetailState @Binding var isPresented: Bool - @State private var detent: Detent = .medium + @AppStorage("iPadInfoPanelDetent") private var detent: Detent = .medium @State private var dragOffset: CGFloat = 0 - private enum Detent { case collapsed, medium, expanded } + private enum Detent: String { case collapsed, medium, expanded } // MARK: - Body From f42efec97593983dedd3d0ceebc692ee4fcd0e29 Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 13:31:54 +0100 Subject: [PATCH 5/8] #147 improving info panel smoothness --- Driveline/UI/iPad/DriveViewerView.swift | 63 +++++++++++++++---------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/Driveline/UI/iPad/DriveViewerView.swift b/Driveline/UI/iPad/DriveViewerView.swift index b1dd9f9..cdb5c08 100644 --- a/Driveline/UI/iPad/DriveViewerView.swift +++ b/Driveline/UI/iPad/DriveViewerView.swift @@ -112,7 +112,9 @@ private struct DriveBottomPanel: View { @Binding var isPresented: Bool @AppStorage("iPadInfoPanelDetent") private var detent: Detent = .medium - @State private var dragOffset: CGFloat = 0 + @State private var currentHeight: CGFloat = 0 + @State private var dragStartHeight: CGFloat = 0 + @State private var isDragging = false private enum Detent: String { case collapsed, medium, expanded } @@ -120,17 +122,22 @@ private struct DriveBottomPanel: View { var body: some View { GeometryReader { geo in - let base = targetHeight(for: detent, in: geo.size.height) - let height = max(60, base - dragOffset) + let availableHeight = geo.size.height + let displayHeight = max(60, currentHeight > 0 ? currentHeight : targetHeight(for: detent, in: availableHeight)) VStack(spacing: 0) { - handle + handle(availableHeight: availableHeight) DriveInfoPanel(state: state) } .frame(maxWidth: .infinity) - .frame(height: height, alignment: .top) + .frame(height: displayHeight, alignment: .top) .background(.regularMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .onAppear { + if currentHeight == 0 { + currentHeight = targetHeight(for: detent, in: availableHeight) + } + } } } @@ -144,7 +151,16 @@ private struct DriveBottomPanel: View { } } - private var handle: some View { + private func nearestDetent(for height: CGFloat, in availableHeight: CGFloat) -> Detent { + let options: [(Detent, CGFloat)] = [ + (.collapsed, targetHeight(for: .collapsed, in: availableHeight)), + (.medium, targetHeight(for: .medium, in: availableHeight)), + (.expanded, targetHeight(for: .expanded, in: availableHeight)) + ] + return options.min(by: { abs($0.1 - height) < abs($1.1 - height) })?.0 ?? .medium + } + + private func handle(availableHeight: CGFloat) -> some View { Capsule() .fill(Color.secondary.opacity(0.5)) .frame(width: 36, height: 5) @@ -154,29 +170,26 @@ private struct DriveBottomPanel: View { .gesture( DragGesture(minimumDistance: 5) .onChanged { value in - dragOffset = value.translation.height + if !isDragging { + isDragging = true + dragStartHeight = currentHeight + } + currentHeight = max(60, dragStartHeight - value.translation.height) } .onEnded { value in - let predicted = value.predictedEndTranslation.height - let prior = detent - withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { - dragOffset = 0 - if predicted > 120 { - switch prior { - case .expanded: detent = .medium - case .medium: detent = .collapsed - case .collapsed: break - } - } else if predicted < -120 { - switch prior { - case .collapsed: detent = .medium - case .medium: detent = .expanded - case .expanded: break - } + isDragging = false + let predictedTranslation = value.predictedEndTranslation.height + if detent == .collapsed && predictedTranslation > 120 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + isPresented = false } + return } - if predicted > 120, prior == .collapsed { - isPresented = false + let predictedHeight = max(60, dragStartHeight - predictedTranslation) + let newDetent = nearestDetent(for: predictedHeight, in: availableHeight) + detent = newDetent + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + currentHeight = targetHeight(for: newDetent, in: availableHeight) } } ) From 827e1f8ef9bcd95e0bbe5142c46ad6557f855bb6 Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 13:35:53 +0100 Subject: [PATCH 6/8] #147 `Updating material of the info panel --- Driveline/UI/iPad/DriveViewerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Driveline/UI/iPad/DriveViewerView.swift b/Driveline/UI/iPad/DriveViewerView.swift index cdb5c08..b38e1f5 100644 --- a/Driveline/UI/iPad/DriveViewerView.swift +++ b/Driveline/UI/iPad/DriveViewerView.swift @@ -131,7 +131,7 @@ private struct DriveBottomPanel: View { } .frame(maxWidth: .infinity) .frame(height: displayHeight, alignment: .top) - .background(.regularMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) + .background(.ultraThinMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .onAppear { if currentHeight == 0 { From 1d2fc768a0dcfc3d777c3262cbf6fe7c4aa097aa Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 13:44:19 +0100 Subject: [PATCH 7/8] #147 tweaks --- Driveline/UI/iPad/DriveViewerView.swift | 30 ++++++++++--------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/Driveline/UI/iPad/DriveViewerView.swift b/Driveline/UI/iPad/DriveViewerView.swift index b38e1f5..d0ae273 100644 --- a/Driveline/UI/iPad/DriveViewerView.swift +++ b/Driveline/UI/iPad/DriveViewerView.swift @@ -19,7 +19,6 @@ struct DriveViewerView: View { @AppStorage("iPadInfoPanelVisible") private var isInspectorPresented: Bool = true @State private var showingDeleteConfirmation: Bool = false @State private var showingEditDrive: Bool = false - @State private var showingMoreMenu: Bool = false @Environment(SpotlightIndexingService.self) private var spotlightIndexingService @Environment(\.modelContext) private var modelContext @@ -54,7 +53,8 @@ struct DriveViewerView: View { .toolbar { DriveViewerToolbar( isInspectorPresented: $isInspectorPresented, - showingMoreMenu: $showingMoreMenu, + showingEditDrive: $showingEditDrive, + showingDeleteConfirmation: $showingDeleteConfirmation, driveState: driveState ) } @@ -70,18 +70,6 @@ struct DriveViewerView: View { } message: { Text(String(localized: "This drive and all its data will be permanently deleted.", comment: "Delete drive confirmation message")) } - .confirmationDialog( - String(localized: "Drive Options", comment: "More menu title"), - isPresented: $showingMoreMenu - ) { - Button(String(localized: "Edit Drive Details", comment: "More menu action")) { - showingEditDrive = true - } - Button(String(localized: "Delete Drive", comment: "More menu action"), role: .destructive) { - showingDeleteConfirmation = true - } - Button.cancel() - } } .sheet(item: $driveState.shareItem) { item in ActivityView(activityItems: [item.url]) @@ -131,7 +119,7 @@ private struct DriveBottomPanel: View { } .frame(maxWidth: .infinity) .frame(height: displayHeight, alignment: .top) - .background(.ultraThinMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) + .background(.regularMaterial, in: UnevenRoundedRectangle(topLeadingRadius: 20, topTrailingRadius: 20, style: .continuous)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .onAppear { if currentHeight == 0 { @@ -201,7 +189,8 @@ private struct DriveBottomPanel: View { private struct DriveViewerToolbar: ToolbarContent { @Binding var isInspectorPresented: Bool - @Binding var showingMoreMenu: Bool + @Binding var showingEditDrive: Bool + @Binding var showingDeleteConfirmation: Bool let driveState: DriveDetailState var body: some ToolbarContent { @@ -224,8 +213,13 @@ private struct DriveViewerToolbar: ToolbarContent { } ToolbarItem(placement: .topBarTrailing) { - Button { - showingMoreMenu = true + Menu { + Button(String(localized: "Edit Drive Details", comment: "More menu action")) { + showingEditDrive = true + } + Button(String(localized: "Delete Drive", comment: "More menu action"), role: .destructive) { + showingDeleteConfirmation = true + } } label: { Image(systemName: Icons.Options.ellipsis) } From b8cf583454d4262bb26c1681d3e6987b6fa5e535 Mon Sep 17 00:00:00 2001 From: Damien Glancy Date: Sat, 27 Jun 2026 13:47:10 +0100 Subject: [PATCH 8/8] #147 Updating README for iPad --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ebd0213..2ecbf3b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Build & Test](https://github.com/dglancy/Driveline/actions/workflows/ios.yml/badge.svg)](https://github.com/dglancy/Driveline/actions/workflows/ios.yml) [![CodeQL](https://github.com/dglancy/Driveline/actions/workflows/codeql.yml/badge.svg)](https://github.com/dglancy/Driveline/actions/workflows/codeql.yml) -A lightweight iOS app that records your drives in the background, then produces exportable maps and GPX files from each one. The idea is simple: connect to your car's Bluetooth and the drive starts recording automatically; disconnect and it stops. No fiddling with the phone. +A lightweight iPhone and iPad app that records your drives in the background, then produces exportable maps and GPX files from each one. The idea is simple: connect to your car's Bluetooth and the drive starts recording automatically; disconnect and it stops. No fiddling with the phone. ## Background @@ -16,6 +16,7 @@ Driveline grew out of a personal need. I run [Targa Trips](https://www.targatrip - **Automatic recording** via Apple Shortcuts and App Intents. You wire up start and stop actions to Bluetooth connect/disconnect automations in the Shortcuts app and Driveline handles the rest. CarPlay connection and disconnection events work just as well as a trigger. - **Route list** showing all your drives grouped by date, with start and end place names, distance, and duration at a glance. - **Route map** that plots the full drive with pinch-to-zoom once a route is finished. +- **iPad layout** using a split-view sidebar for the drives list alongside a full-screen map viewer with a collapsible info panel. Panel visibility is remembered across sessions. - **Merge routes** to join two drives end-to-end into a single route, useful when you forget to start recording and pick it up partway through. - **GPX export** in standard format, compatible with Strava, Komoot, or any other mapping tool that accepts GPX files. - **PNG export** that renders a clean map snapshot with the route drawn on it, suitable for sharing. @@ -55,7 +56,8 @@ Driveline/ │ ├── Common/ Shared views, buttons, map content, presenters used across screens │ ├── DriveDetail/ Drive detail and edit screens │ ├── FullScreenMap/ Full-screen map view -│ ├── Home/ Home/route list screen +│ ├── Home/ Home/route list screen (iPhone) +│ ├── iPad/ Split-view layout for iPad (sidebar + map viewer + info panel) │ ├── MergeDrives/ Merge drives screen │ ├── Onboarding/ First-launch onboarding flow (location permissions, automations setup) │ ├── Recording/ In-progress recording screen @@ -86,7 +88,7 @@ Long-running background work (place name and weather backfill, drive category pr **Language and frameworks** - Swift 6.3 with strict concurrency throughout -- SwiftUI on iOS 26+ +- SwiftUI on iOS 26+, with an adaptive iPad layout using `NavigationSplitView` - SwiftData for persistence (three model types: `Drive`, `Position`, and `Weather`) **Apple frameworks** @@ -146,7 +148,7 @@ Run `./build-MLTrainingDataPrepTool.sh` from the repo root to build the tool and **Requirements** - Xcode 26 or later (the project targets iOS 26.0) -- A physical iPhone for any testing that involves background location or Shortcuts automations. The simulator does not support background location in a meaningful way. +- A physical iPhone or iPad for any testing that involves background location or Shortcuts automations. The simulator does not support background location in a meaningful way. **Steps**