From fa43baf6bfda252ea82d5c2bae7f2c61722e8908 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 13:31:46 -0700 Subject: [PATCH 1/6] Add live-region welcome to Locations --- Where/WhereCore/README.md | 4 +- .../Location/CurrentRegionResolver.swift | 21 +++ .../Sources/Location/LocationIngestor.swift | 5 + .../Preferences/WherePreferences.swift | 17 ++ Where/WhereCore/Sources/WhereServices.swift | 4 + .../Tests/CurrentRegionResolverTests.swift | 114 ++++++++++++++ .../Tests/WherePreferencesTests.swift | 13 ++ Where/WhereUI/README.md | 7 +- .../locations.WelcomeBack_iPhone.png | 3 + .../locations.WelcomeBack_iPhone_dark.png | 3 + .../locations.WelcomeFirst_iPhone.png | 3 + .../locations.WelcomeFirst_iPhone_ax5.png | 3 + .../locations.WelcomeFirst_iPhone_dark.png | 3 + .../Primary/LocationWelcomeModel.swift | 62 ++++++++ .../Primary/LocationWelcomeOverlay.swift | 54 +++++++ .../Sources/Primary/LocationsView.swift | 65 ++++++++ .../Sources/Primary/RegionWelcomeCard.swift | 145 ++++++++++++++++++ .../Sources/Resources/Localizable.xcstrings | 48 ++++++ .../Sources/Shared/WhereStylesheet.swift | 79 ++++++++++ .../Tests/LocationWelcomeModelTests.swift | 117 ++++++++++++++ .../WhereUI/Tests/WhereStylesheetTests.swift | 23 +++ 21 files changed, 791 insertions(+), 2 deletions(-) create mode 100644 Where/WhereCore/Sources/Location/CurrentRegionResolver.swift create mode 100644 Where/WhereCore/Tests/CurrentRegionResolverTests.swift create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png create mode 100644 Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift create mode 100644 Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift create mode 100644 Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift create mode 100644 Where/WhereUI/Tests/LocationWelcomeModelTests.swift diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 07cbb2edc..bedbd455e 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -70,6 +70,8 @@ one it belongs to rather than to a god-object: - **`PlannedStayLocationVerifier`** — gets a current location and compares it with the selected region. The configured drift threshold expands the accepted area outside the region boundary. A missing location or missing geometry returns an unavailable result. +- **`CurrentRegionResolver`** — returns the current tracked region only while automatic recording + is authorized. It returns `nil` when no live fix exists or the fix is outside tracked regions. - **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a given `WhereServices`: a plausible current year of living in New York with @@ -172,7 +174,7 @@ one it belongs to rather than to a god-object: - **`WherePreferences`** — persisted user intent (onboarding, reminder / summary schedules, presentation theme, and Locations-card GPS-dot and estimated-time/planning visibility) plus the - year-keyed Location-card counts and Codable recording-warning generation used for presentation + year-keyed Location-card counts, last welcomed region, and recording-warning generation used for presentation continuity, behind a `KeyValueStore`. It also owns the vendor-neutral `DiagnosticReportingConfiguration`: crash reports default On, replay Off, and remote logs Off in Release / Warning in Debug. `WherePreferences` encodes diff --git a/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift b/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift new file mode 100644 index 000000000..1be5aaf48 --- /dev/null +++ b/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift @@ -0,0 +1,21 @@ +import RegionKit + +/// Resolves the device's current tracked region while automatic recording is authorized. +public struct CurrentRegionResolver: Sendable { + private let ingestor: LocationIngestor + private let attributor: any RegionAttributing + + init(ingestor: LocationIngestor, attributor: any RegionAttributing) { + self.ingestor = ingestor + self.attributor = attributor + } + + /// Returns a tracked region from a best-effort live fix, or `nil` when no welcome is valid. + public func resolve() async -> Region? { + guard await ingestor.isRecordingAuthorized else { return nil } + guard let sample = await ingestor.currentLocation(), !Task.isCancelled else { return nil } + guard await ingestor.isRecordingAuthorized else { return nil } + let region = attributor.region(at: sample.coordinate) + return region == .other ? nil : region + } +} diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 9556344b0..7c8adba94 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -324,6 +324,11 @@ public actor LocationIngestor { isMonitoring } + /// Whether the current device policy authorizes automatic location samples. + public var isRecordingAuthorized: Bool { + if case .open = recordingAuthority { true } else { false } + } + /// Number of samples currently waiting to be re-persisted. Exposed for /// tests; production callers should treat this as opaque. public var retryQueueDepth: Int { diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index c25bec45c..7e7136e9e 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -222,6 +222,22 @@ public final class WherePreferences { store.set(snapshots, forKey: Keys.lastSeenLocationDayCounts.rawValue) } + /// The last live region whose Locations welcome the user dismissed. + public var lastWelcomedRegion: Region? { + get { + guard let rawValue = store.object(forKey: Keys.lastWelcomedRegion.rawValue) as? String + else { return nil } + return Region(rawValue: rawValue) + } + set { + if let newValue { + store.set(newValue.rawValue, forKey: Keys.lastWelcomedRegion.rawValue) + } else { + store.removeObject(forKey: Keys.lastWelcomedRegion.rawValue) + } + } + } + /// Clear every persisted preference so the next launch behaves like a fresh /// install: onboarding shows again, presentation and notification settings /// revert to defaults, and UI continuity snapshots are forgotten. @@ -253,6 +269,7 @@ public final class WherePreferences { "where.recordingConfigurationWarningRegistration" case driftThresholdMeters = "where.driftThresholdMeters" case lastSeenLocationDayCounts = "where.lastSeenLocationDayCounts" + case lastWelcomedRegion = "where.lastWelcomedRegion" } private static var isDebugBuild: Bool { diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index abe7cb7f1..9f1bf9c35 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -63,6 +63,8 @@ public struct WhereServices: Sendable { public let plannedStays: PlannedStayCoordinator /// Best-effort current-location verification for the planned-stay editor. public let plannedStayLocation: PlannedStayLocationVerifier + /// Best-effort live tracked-region lookup for presentation acknowledgements. + public let currentRegion: CurrentRegionResolver /// Data-quality issue detection for the Resolve tab. public let resolution: DataIssueScanner /// The persistence boundary, retained so `dataChangeUpdates()` can hand out @@ -275,6 +277,7 @@ public struct WhereServices: Sendable { ingestor: ingestor, attributor: attributor, ) + let currentRegion = CurrentRegionResolver(ingestor: ingestor, attributor: attributor) self.reports = reports self.evidence = evidence self.reminders = reminders @@ -287,6 +290,7 @@ public struct WhereServices: Sendable { self.backup = backup self.plannedStays = plannedStays self.plannedStayLocation = plannedStayLocation + self.currentRegion = currentRegion self.resolution = resolution self.store = store self.attributor = attributor diff --git a/Where/WhereCore/Tests/CurrentRegionResolverTests.swift b/Where/WhereCore/Tests/CurrentRegionResolverTests.swift new file mode 100644 index 000000000..414071315 --- /dev/null +++ b/Where/WhereCore/Tests/CurrentRegionResolverTests.swift @@ -0,0 +1,114 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) @testable import WhereCore + +struct CurrentRegionResolverTests { + @Test func resolvesTrackedRegionWhileRecordingIsAuthorized() async throws { + let (services, source) = try makeServices() + source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194)) + try await services.ingestor.authorizeRecording() + + #expect(await services.currentRegion.resolve() == .california) + } + + @Test func inactiveRecordingDoesNotRequestAWelcomeRegion() async throws { + let (services, source) = try makeServices() + source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194)) + + #expect(await services.currentRegion.resolve() == nil) + } + + @Test func missingFixDoesNotResolveAWelcomeRegion() async throws { + let (services, _) = try makeServices() + try await services.ingestor.authorizeRecording() + + #expect(await services.currentRegion.resolve() == nil) + } + + @Test func locationOutsideTrackedRegionsDoesNotResolveAWelcomeRegion() async throws { + let (services, source) = try makeServices( + attributor: RegionAttributor(for: [.california]), + ) + source.setNextRequestedLocation(sample(latitude: 40.7128, longitude: -74.0060)) + try await services.ingestor.authorizeRecording() + + #expect(await services.currentRegion.resolve() == nil) + } + + @Test func authorizationRevokedDuringFixRequestDoesNotResolveAWelcomeRegion() async throws { + let source = GatedWelcomeLocationSource() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + ) + try await services.ingestor.authorizeRecording() + let resolution = Task { await services.currentRegion.resolve() } + await source.waitUntilRequested() + + await services.ingestor.revokeRecordingAuthorization() + await source.resolve(with: sample(latitude: 37.7749, longitude: -122.4194)) + + #expect(await resolution.value == nil) + } + + private func makeServices( + attributor: any RegionAttributing = RegionAttributor.shared, + ) throws -> (WhereServices, ScriptedLocationSource) { + let source = ScriptedLocationSource() + return try ( + WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + attributor: attributor, + ), + source, + ) + } + + private func sample(latitude: Double, longitude: Double) -> LocationSample { + LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 0), + coordinate: Coordinate(latitude: latitude, longitude: longitude), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + } +} + +private actor GatedWelcomeLocationSource: LocationSource { + nonisolated let sampleStream = AsyncStream { $0.finish() } + nonisolated let authorizationUpdates = AsyncStream { $0.finish() } + + private var requestContinuation: CheckedContinuation? + private var requestWaiters: [CheckedContinuation] = [] + private var didRequest = false + + func start() async {} + func stop() async {} + + func requestCurrentLocation() async -> LocationSample? { + didRequest = true + for waiter in requestWaiters { + waiter.resume() + } + requestWaiters.removeAll() + return await withCheckedContinuation { requestContinuation = $0 } + } + + func currentAuthorization() async -> LocationAuthorizationStatus { + .always + } + + func requestPermission() async throws {} + + func waitUntilRequested() async { + guard didRequest == false else { return } + await withCheckedContinuation { requestWaiters.append($0) } + } + + func resolve(with sample: LocationSample?) { + requestContinuation?.resume(returning: sample) + requestContinuation = nil + } +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index f3dad643d..151152529 100644 --- a/Where/WhereCore/Tests/WherePreferencesTests.swift +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -26,6 +26,7 @@ struct WherePreferencesTests { ) #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + #expect(preferences.lastWelcomedRegion == nil) } @Test(arguments: [ @@ -127,6 +128,16 @@ struct WherePreferencesTests { #expect(preferences.lastSeenLocationDayCounts(in: 2026) == counts2026) } + @Test func lastWelcomedRegionRoundTripsAndClears() { + let preferences = preferences() + + preferences.lastWelcomedRegion = .newYork + #expect(preferences.lastWelcomedRegion == .newYork) + + preferences.lastWelcomedRegion = nil + #expect(preferences.lastWelcomedRegion == nil) + } + @Test func estimatedTimeUsesTheLegacyLocationsVisibilityKey() { let store = InMemoryKeyValueStore() store.set(false, forKey: "where.showsLocationForecastsOnLocationsTab") @@ -193,6 +204,7 @@ struct WherePreferencesTests { preferences.recordingConfigurationWarningRegistration = recordingWarning preferences.driftThresholdMeters = 25000 preferences.setLastSeenLocationDayCounts([.california: 100], in: 2026) + preferences.lastWelcomedRegion = .california preferences.diagnosticReportingConfiguration = DiagnosticReportingConfiguration( sharesCrashReports: false, sharesSessionReplays: true, @@ -219,6 +231,7 @@ struct WherePreferencesTests { ) #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + #expect(preferences.lastWelcomedRegion == nil) #expect( preferences.diagnosticReportingConfiguration == DiagnosticReportingConfiguration.currentBuildDefaults, diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 844325690..44cd5b505 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -119,7 +119,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's demo, and completion orchestration) and **`OnboardingImportRecoveryModel`** (the sidecar/store recovery handshake after an interrupted onboarding import), and **`LocationCardsPresentationModel`** (the last primary-card counts and order - the user saw). The Location model holds saved values until the card surface + the user saw), and **`LocationWelcomeModel`** (the current-region welcome and + its persisted acknowledgement). The Location model holds saved values until the card surface is visible and unobscured, holds them there for another half second, then advances every changed number and any live two-card reversal in one animated beat, adding one light haptic. Decreases, first visits, hidden updates, and @@ -129,6 +130,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's ### Reusable views & styling +- **`RegionWelcomeCard`** — a centered Locations overlay that combines a region's emoji, + icon, outline, Liquid Glass card treatment, and passport ink. It uses a spring transition + and an opacity-only transition when Reduce Motion is enabled. + - **`OnboardingView` / `OnboardingFlowModel`** — the rendered first-run flow and its view-scoped observable coordinator, registered for the launch's `OnboardingGate` and handed its `LifecycleGateHandle`. The gate roots the diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png new file mode 100644 index 000000000..3ab2ab68c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3684a2b70a98e67e728f6d3e285fcd287b7dd9ec5e11bea3cf0915e4c648a101 +size 2797704 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png new file mode 100644 index 000000000..f10e7154a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:642e25cc801a4fc8e0c5cafdf3da8088be5bc534641f19bf71e4da414d5a0f12 +size 2556220 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png new file mode 100644 index 000000000..af5dcc45f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c4c5cd0c516c2dae796f5917538c0ac5c131ef1084f6516ca554baf25c28c4b +size 2799104 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png new file mode 100644 index 000000000..6c1725a07 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38527587be172f4186507134dc91481434b9f515f1a533a0083aebf726d434ff +size 3698248 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png new file mode 100644 index 000000000..98a393aff --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa8e40e02270fe32e8e41213f3c9868b6511c06d49ab75fb6c325e6b6b30da27 +size 2556246 diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift new file mode 100644 index 000000000..b0864a46b --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift @@ -0,0 +1,62 @@ +import Observation +import RegionKit +import WhereCore + +/// Presentation state for the live-region welcome on the Locations tab. +@MainActor +@Observable +public final class LocationWelcomeModel { + public struct Presentation: Equatable { + public enum Greeting: Equatable { + case first + case returnVisit + } + + public let region: Region + public let greeting: Greeting + } + + public private(set) var presentation: Presentation? + + private let resolver: CurrentRegionResolver + private let preferences: WherePreferences + private var resolutionSequence: UInt64 = 0 + + init(services: WhereServices, preferences: WherePreferences) { + resolver = services.currentRegion + self.preferences = preferences + } + + /// Resolves a fresh welcome while the Locations root is visible. + func resolve() async { + guard presentation == nil else { return } + let (sequence, overflow) = resolutionSequence.addingReportingOverflow(1) + precondition(!overflow, "Location welcome resolution sequence exhausted UInt64.") + resolutionSequence = sequence + + guard let region = await resolver.resolve() else { return } + guard !Task.isCancelled, sequence == resolutionSequence, presentation == nil else { return } + let previous = preferences.lastWelcomedRegion + guard region != previous else { return } + presentation = Presentation( + region: region, + greeting: previous == nil ? .first : .returnVisit, + ) + } + + func dismiss() { + guard let presentation else { return } + preferences.lastWelcomedRegion = presentation.region + self.presentation = nil + } + + #if DEBUG + /// Seeds a deterministic state for previews and image snapshots. + @_spi(Testing) public func presentForTesting( + region: Region, + greeting: Presentation.Greeting, + ) { + presentation = Presentation(region: region, greeting: greeting) + } + #endif +} diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift new file mode 100644 index 000000000..f6c7a0acb --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift @@ -0,0 +1,54 @@ +import SwiftUI +import UIKit + +/// The modal scrim and adaptive placement for a Locations welcome card. +struct LocationWelcomeOverlay: View { + let presentation: LocationWelcomeModel.Presentation + let dismissAction: () -> Void + + @AccessibilityFocusState private var isCardFocused: Bool + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + ZStack { + Color.black + .opacity(stylesheet.locationWelcome.scrimOpacity) + .ignoresSafeArea() + .accessibilityHidden(true) + + GeometryReader { proxy in + ScrollView { + RegionWelcomeCard( + presentation: presentation, + dismissAction: dismissAction, + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.vertical, stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, minHeight: proxy.size.height) + .accessibilityFocused($isCardFocused) + } + .scrollBounceBehavior(.basedOnSize) + } + } + .contentShape(Rectangle()) + .accessibilityAddTraits(.isModal) + .onAppear { + isCardFocused = true + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + .onDisappear { + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + } +} + +#if DEBUG + #Preview { + LocationWelcomeOverlay( + presentation: .init(region: .california, greeting: .first), + dismissAction: {}, + ) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 6cc81ea6f..f7d9c08da 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -14,8 +14,10 @@ struct LocationsView: View { @State private var showingResolution = false @State private var plannedStayEditorTarget: PlannedStayEditorTarget? + @State private var isLocationsSurfaceVisible = false @State private var isCardSurfaceVisible = false @State private var cardPresentation: LocationCardsPresentationModel + @State private var welcome: LocationWelcomeModel /// Drives the region cards' tilt-reactive light sheen. Started/stopped /// with the view's lifecycle; a no-op on hardware without device motion. @@ -33,10 +35,28 @@ struct LocationsView: View { isCardSurfaceVisible && !showingResolution && plannedStayEditorTarget == nil + && welcome.presentation == nil + } + + private var isWelcomeLookupActive: Bool { + isLocationsSurfaceVisible + && !showingResolution + && plannedStayEditorTarget == nil } init(report: YearReportModel) { + self.init( + report: report, + welcome: LocationWelcomeModel( + services: report.services, + preferences: report.preferences, + ), + ) + } + + init(report: YearReportModel, welcome: LocationWelcomeModel) { self.report = report + _welcome = State(initialValue: welcome) _cardPresentation = State(initialValue: LocationCardsPresentationModel( preferences: report.preferences, year: report.selectedYear, @@ -47,6 +67,8 @@ struct LocationsView: View { NavigationStack { screen .navigationBarTitleDisplayMode(.inline) + .onAppear { isLocationsSurfaceVisible = true } + .onDisappear { isLocationsSurfaceVisible = false } .toolbar { // Resolve is a toolbar action here rather than its own tab: // it appears (badged with the count) only while there are @@ -63,6 +85,21 @@ struct LocationsView: View { } } } + .accessibilityHidden(welcome.presentation != nil) + .overlay { + if let presentation = welcome.presentation { + LocationWelcomeOverlay( + presentation: presentation, + dismissAction: welcome.dismiss, + ) + .transition(stylesheet.locationWelcome.motion.transition) + } + } + .animation(stylesheet.locationWelcome.motion.animation, value: welcome.presentation) + .task(id: isWelcomeLookupActive) { + guard isWelcomeLookupActive else { return } + await welcome.resolve() + } .onAppear { tilt.start() } .onDisappear { tilt.stop() } .sheet(isPresented: $showingResolution) { @@ -373,11 +410,39 @@ private struct ResolveToolbarLabel: View { report: PreviewSupport.loadedYearReportModelWithLocationDotsHidden(), ) } + whereSnapshot( + name: "WelcomeFirst", + configurations: .phoneLightDark + [ + SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + ], + measurementReadiness: .immediate, + ) { + welcomeSnapshot(greeting: .first) + } + whereSnapshot( + name: "WelcomeBack", + configurations: .phoneLightDark, + measurementReadiness: .immediate, + ) { + welcomeSnapshot(greeting: .returnVisit) + } } private static func forecastsHiddenReport() -> YearReportModel { PreviewSupport.loadedYearReportModelWithEstimatedTimeHidden() } + + private static func welcomeSnapshot( + greeting: LocationWelcomeModel.Presentation.Greeting, + ) -> some View { + let report = PreviewSupport.loadedYearReportModel() + let welcome = LocationWelcomeModel( + services: report.services, + preferences: report.preferences, + ) + welcome.presentForTesting(region: .california, greeting: greeting) + return LocationsView(report: report, welcome: welcome) + } } #Preview { diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift new file mode 100644 index 000000000..9f1023c88 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -0,0 +1,145 @@ +import RegionKit +import SFSafeSymbols +import SwiftUI + +/// A passport-inspired acknowledgement of the user's current tracked region. +struct RegionWelcomeCard: View { + let presentation: LocationWelcomeModel.Presentation + let dismissAction: () -> Void + + @State private var regionPath = Path() + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + @Environment(\.regionOutlinePathCache) private var regionOutlinePathCache + + private var welcome: WhereStylesheet.LocationWelcomeStyle { + stylesheet.locationWelcome + } + + private var regionStyle: RegionStyle { + regionStyles.style(for: presentation.region) + } + + private var title: String { + switch presentation.greeting { + case .first: + String(localized: .locationWelcomeFirstTitle(presentation.region.localizedName)) + case .returnVisit: + String(localized: .locationWelcomeReturnTitle(presentation.region.localizedName)) + } + } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: welcome.cornerRadius) + VStack(spacing: welcome.contentSpacing) { + HStack { + Text(regionStyle.emoji) + .font(.largeTitle) + .accessibilityHidden(true) + Spacer() + PassportSeal(systemSymbol: regionStyle.symbol, tint: regionStyle.tint) + } + + if let artwork = stylesheet.card.regular.regionShape { + RegionOutlineArtwork( + path: regionPath, + tint: regionStyle.tint, + style: artwork.watermark, + ) + .frame(maxWidth: .infinity) + .frame(height: welcome.artworkHeight) + } + + VStack(spacing: stylesheet.spacing.medium) { + Text(title) + .font(.title2.bold()) + .fontDesign(.serif) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: .locationWelcomeMessage( + presentation.region.localizedName, + ))) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + .padding(welcome.padding) + .frame(maxWidth: welcome.maxWidth) + .background { + ZStack { + SecurityPrintRosette( + tint: regionStyle.tint, + wobble: stylesheet.card.regular.rosette.wobble, + lineWidth: stylesheet.card.regular.rosette.lineWidth, + primaryRingSpacing: stylesheet.card.regular.rosette.primaryRingSpacing, + secondaryRingSpacing: stylesheet.card.regular.rosette.secondaryRingSpacing, + primaryOpacity: stylesheet.card.rosetteFill.primary, + secondaryOpacity: stylesheet.card.rosetteFill.secondary, + ) + shape.fill(.clear) + } + .clipShape(shape) + .allowsHitTesting(false) + } + .glassEffect( + .regular.tint(regionStyle.tint.opacity(welcome.glassTintOpacity)), + in: shape, + ) + .overlay { + ZStack { + shape.strokeBorder( + regionStyle.tint.opacity(welcome.outlineOpacity), + lineWidth: welcome.outlineWidth, + ) + shape.inset(by: welcome.inset).strokeBorder( + regionStyle.tint.opacity(welcome.outlineOpacity), + style: StrokeStyle( + lineWidth: welcome.outlineWidth, + dash: [welcome.insetDashLength, welcome.insetDashSpacing], + ), + ) + } + .allowsHitTesting(false) + } + .overlay(alignment: .topTrailing) { + Button( + String(localized: .locationWelcomeDismiss), + systemSymbol: .xmark, + action: dismissAction, + ) + .labelStyle(.iconOnly) + .frame(width: 44, height: 44) + .background(.regularMaterial, in: Circle()) + .contentShape(Circle()) + .offset(welcome.closeOffset) + } + .shadow( + color: regionStyle.tint.opacity(welcome.shadowOpacity), + radius: welcome.shadowRadius, + y: welcome.shadowOffsetY, + ) + .task(id: presentation.region) { + guard let regionOutlinePathCache else { return } + let loaded = await regionOutlinePathCache.path( + for: presentation.region, + resolution: .medium, + ) + guard !Task.isCancelled else { return } + regionPath = loaded + } + } +} + +#if DEBUG + #Preview { + RegionWelcomeCard( + presentation: .init(region: .california, greeting: .returnVisit), + dismissAction: {}, + ) + .padding(32) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 6e4e0be3b..93282fa2c 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3475,6 +3475,54 @@ } } }, + "locationWelcome.dismiss" : { + "comment" : "Accessibility label for the close button on the live-region welcome card.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close welcome" + } + } + } + }, + "locationWelcome.firstTitle" : { + "comment" : "Title shown for the first live tracked region. The argument is the localized region name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to %@" + } + } + } + }, + "locationWelcome.message" : { + "comment" : "Explains automatic day counting. The argument is the localized region name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starting today, days Where records you here will count toward %@." + } + } + } + }, + "locationWelcome.returnTitle" : { + "comment" : "Title shown after the user moves to another tracked region. The argument is the localized region name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome back to %@" + } + } + } + }, "locations.elsewhere.subtitle" : { "comment" : "Subtitle on the Locations tab's Elsewhere entry card: region count.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 7ea0efabe..8a4052374 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -18,6 +18,7 @@ struct WhereStylesheet: BStylesheet { var size = Size() var card = CardStyles.standard var locationCardStack = LocationCardStackStyle.standard + var locationWelcome = LocationWelcomeStyle.standard var calendar = CalendarStyle.standard var appIcon = AppIconStyle.standard var timeline = TimelineStyle.standard @@ -82,6 +83,7 @@ struct WhereStylesheet: BStylesheet { if traits.accessibility.isReduceMotionEnabled { card.dayCount = .reducedMotion locationCardStack.overtake = .reducedMotion + locationWelcome.motion = .reduced developerOverlay.menu.motion = .reduced } @@ -98,6 +100,83 @@ struct WhereStylesheet: BStylesheet { static let `default` = WhereStylesheet() } +// MARK: - Location welcome + +extension WhereStylesheet { + /// Appearance and motion for the live-region welcome over Locations. + struct LocationWelcomeStyle: Equatable { + var maxWidth: CGFloat + var cornerRadius: CGFloat + var padding: CGFloat + var contentSpacing: CGFloat + var artworkHeight: CGFloat + var scrimOpacity: Double + var glassTintOpacity: Double + var outlineOpacity: Double + var outlineWidth: CGFloat + var inset: CGFloat + var insetDashLength: CGFloat + var insetDashSpacing: CGFloat + var shadowOpacity: Double + var shadowRadius: CGFloat + var shadowOffsetY: CGFloat + var closeOffset: CGSize + var motion: Motion + + struct Motion: Equatable { + var animation: Animation + var scale: CGFloat + var verticalOffset: CGFloat + var usesSpatialMotion: Bool + + var transition: AnyTransition { + let base: AnyTransition = usesSpatialMotion + ? .scale(scale: scale).combined(with: .offset(y: verticalOffset)) + .combined(with: .opacity) + : .opacity + return .asymmetric( + insertion: base.animation(animation), + removal: base.animation(animation), + ) + } + + static let standard = Motion( + animation: .spring(duration: 0.62, bounce: 0.32), + scale: 0.78, + verticalOffset: 34, + usesSpatialMotion: true, + ) + + static let reduced = Motion( + animation: .easeInOut(duration: 0.18), + scale: 1, + verticalOffset: 0, + usesSpatialMotion: false, + ) + } + + static let standard = LocationWelcomeStyle( + maxWidth: 390, + cornerRadius: 30, + padding: 24, + contentSpacing: 16, + artworkHeight: 132, + scrimOpacity: 0.28, + glassTintOpacity: 0.2, + outlineOpacity: 0.28, + outlineWidth: 1, + inset: 9, + insetDashLength: 5, + insetDashSpacing: 4, + shadowOpacity: 0.42, + shadowRadius: 30, + shadowOffsetY: 16, + closeOffset: CGSize(width: 8, height: -8), + motion: .standard, + ) + } +} + // MARK: - Location card stack extension WhereStylesheet { diff --git a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift new file mode 100644 index 000000000..770fe2f5c --- /dev/null +++ b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift @@ -0,0 +1,117 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct LocationWelcomeModelTests { + @Test func firstResolvedRegionPresentsAFirstGreeting() async throws { + let fixture = try await fixture(region: .california) + + await fixture.model.resolve() + + #expect(fixture.model.presentation == .init(region: .california, greeting: .first)) + } + + @Test func dismissalPersistsAndSuppressesTheSameRegion() async throws { + let fixture = try await fixture(region: .california) + await fixture.model.resolve() + fixture.model.dismiss() + + let relaunched = LocationWelcomeModel( + services: fixture.services, + preferences: fixture.preferences, + ) + await relaunched.resolve() + + #expect(fixture.preferences.lastWelcomedRegion == .california) + #expect(relaunched.presentation == nil) + } + + @Test func differentRegionPresentsAReturnGreeting() async throws { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.lastWelcomedRegion = .california + let fixture = try await fixture(region: .newYork, preferences: preferences) + + await fixture.model.resolve() + + #expect(fixture.model.presentation == .init(region: .newYork, greeting: .returnVisit)) + } + + @Test func inactiveRecordingDoesNotPresent() async throws { + let fixture = try fixtureWithoutRecording(region: .california) + + await fixture.model.resolve() + #expect(fixture.model.presentation == nil) + } + + @Test func cancelledResolutionDoesNotPublishALateRegion() async throws { + let source = GatedCurrentLocationSource() + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try Self.services(locationSource: source) + try await services.ingestor.authorizeRecording() + let model = LocationWelcomeModel(services: services, preferences: preferences) + let task = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + + task.cancel() + try await source.resolveRequest( + at: 0, + with: Self.sample(region: .california), + ) + await task.value + + #expect(model.presentation == nil) + } + + private func fixture( + region: Region, + preferences: WherePreferences = WherePreferences(store: InMemoryKeyValueStore()), + ) async throws -> Fixture { + let fixture = try fixtureWithoutRecording(region: region, preferences: preferences) + try await fixture.services.ingestor.authorizeRecording() + return fixture + } + + private func fixtureWithoutRecording( + region: Region, + preferences: WherePreferences = WherePreferences(store: InMemoryKeyValueStore()), + ) throws -> Fixture { + let source = ScriptedLocationSource() + try source.setNextRequestedLocation(Self.sample(region: region)) + let services = try Self.services(locationSource: source) + return Fixture( + model: LocationWelcomeModel(services: services, preferences: preferences), + services: services, + preferences: preferences, + ) + } + + private static func services(locationSource: any LocationSource) throws -> WhereServices { + try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: locationSource, + ) + } + + private static func sample(region: Region) throws -> LocationSample { + let coordinates = [ + Region.california: Coordinate(latitude: 37.7749, longitude: -122.4194), + Region.newYork: Coordinate(latitude: 40.7128, longitude: -74.0060), + ] + let coordinate = try #require(coordinates[region]) + return LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 0), + coordinate: coordinate, + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + } + + private struct Fixture { + let model: LocationWelcomeModel + let services: WhereServices + let preferences: WherePreferences + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 4150b8fe1..ade37d4f1 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -45,6 +45,27 @@ struct WhereStylesheetTests { #expect(style.spacing.xxxLarge == 20) } + @Test func locationWelcomeStyle() { + let welcome = style.locationWelcome + #expect(welcome.maxWidth == 390) + #expect(welcome.cornerRadius == 30) + #expect(welcome.padding == 24) + #expect(welcome.contentSpacing == 16) + #expect(welcome.artworkHeight == 132) + #expect(welcome.scrimOpacity == 0.28) + #expect(welcome.glassTintOpacity == 0.2) + #expect(welcome.outlineOpacity == 0.28) + #expect(welcome.outlineWidth == 1) + #expect(welcome.inset == 9) + #expect(welcome.insetDashLength == 5) + #expect(welcome.insetDashSpacing == 4) + #expect(welcome.shadowOpacity == 0.42) + #expect(welcome.shadowRadius == 30) + #expect(welcome.shadowOffsetY == 16) + #expect(welcome.closeOffset == CGSize(width: 8, height: -8)) + #expect(welcome.motion == .standard) + } + @Test func regularCardStyle() { let card = style.card.regular #expect(style.card.estimatedProgressOpacity == 0.3) @@ -815,6 +836,8 @@ struct WhereStylesheetTests { #expect(resolved.locationCardStack.overtake == .reducedMotion) #expect(resolved.locationCardStack.overtake.minimumOpacity == 0.82) #expect(resolved.locationCardStack.overtake.usesSpatialMotion == false) + #expect(resolved.locationWelcome.motion == .reduced) + #expect(resolved.locationWelcome.motion.usesSpatialMotion == false) #expect(resolved.developerOverlay.menu.motion == .reduced) #expect(resolved.developerOverlay.menu.motion.usesSpatialMotion == false) } From d6381d41522c55bcb4ba1b530752bfa8b8725b49 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 14:18:04 -0700 Subject: [PATCH 2/6] Refine live-region welcome card --- .../locations.WelcomeBack_iPhone.png | 4 +- .../locations.WelcomeBack_iPhone_dark.png | 4 +- .../locations.WelcomeFirst_iPhone.png | 4 +- .../locations.WelcomeFirst_iPhone_ax5.png | 4 +- .../locations.WelcomeFirst_iPhone_dark.png | 4 +- .../Sources/Primary/RegionWelcomeCard.swift | 83 +++++++++++++------ .../Sources/Shared/WhereStylesheet.swift | 28 ++++++- .../WhereUI/Tests/WhereStylesheetTests.swift | 8 +- 8 files changed, 98 insertions(+), 41 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png index 03cf0e686..5c73e1a29 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:59df8794ab00fe6c03676e47f48e0add5d56e067ced85ff451dc383f5228c49a -size 2473839 +oid sha256:0d88d60c8ec4031e68d6c1a22d8b250ef917701c6ab0a0cc767cc2b1d65d04e0 +size 2207608 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png index 250b81d00..ad0ad869c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08a37de920f44bc901cd3f155954be553c546378ea418551aee2e243ad283c54 -size 2329149 +oid sha256:90e75a8e09e3f342731ab81fb042154db2c55a4c3034c168c6d592b17dad8202 +size 1968828 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png index 879b8f8e1..bb872960d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:317f2930fd3cb380dd16ed0e7c2d7d9367876857c5688316a2c17112e2f9b7db -size 2474825 +oid sha256:8fbb3a8826d18fbaa36344b71b402190739f48fb55cc01261c4386937ea2424e +size 2211357 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png index 93fb92693..4228d722b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93133c065557bbf0bbcbccacef25b68f434fb3b90df5860aa05b243c99c4373a -size 3925363 +oid sha256:efe7d10eac637e77834ed2519f8352ee707bd9dc37709e3b42b81c1ff2a3cb88 +size 3068507 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png index d309e1e11..e0e7b40bc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:957189763a06698237b197187e8adaa99ce7bcb08b216b441eed529cfe083e37 -size 2328792 +oid sha256:afe966bbd6dc20f3fe1382dc226a458f3f997cd578678250299e44013427621c +size 1969112 diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift index 9f1023c88..ed4ad1528 100644 --- a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -40,16 +40,6 @@ struct RegionWelcomeCard: View { PassportSeal(systemSymbol: regionStyle.symbol, tint: regionStyle.tint) } - if let artwork = stylesheet.card.regular.regionShape { - RegionOutlineArtwork( - path: regionPath, - tint: regionStyle.tint, - style: artwork.watermark, - ) - .frame(maxWidth: .infinity) - .frame(height: welcome.artworkHeight) - } - VStack(spacing: stylesheet.spacing.medium) { Text(title) .font(.title2.bold()) @@ -70,24 +60,39 @@ struct RegionWelcomeCard: View { .frame(maxWidth: welcome.maxWidth) .background { ZStack { - SecurityPrintRosette( - tint: regionStyle.tint, - wobble: stylesheet.card.regular.rosette.wobble, - lineWidth: stylesheet.card.regular.rosette.lineWidth, - primaryRingSpacing: stylesheet.card.regular.rosette.primaryRingSpacing, - secondaryRingSpacing: stylesheet.card.regular.rosette.secondaryRingSpacing, - primaryOpacity: stylesheet.card.rosetteFill.primary, - secondaryOpacity: stylesheet.card.rosetteFill.secondary, - ) shape.fill(.clear) + .glassEffect( + .regular.tint(regionStyle.tint.opacity(welcome.glassTintOpacity)), + in: shape, + ) + + shape.fill(.background) + .opacity(welcome.paperOpacity) + + ZStack { + SecurityPrintRosette( + tint: regionStyle.tint, + wobble: stylesheet.card.regular.rosette.wobble, + lineWidth: stylesheet.card.regular.rosette.lineWidth, + primaryRingSpacing: stylesheet.card.regular.rosette.primaryRingSpacing, + secondaryRingSpacing: stylesheet.card.regular.rosette.secondaryRingSpacing, + primaryOpacity: stylesheet.card.rosetteFill.primary, + secondaryOpacity: stylesheet.card.rosetteFill.secondary, + ) + + if let artwork = stylesheet.card.regular.regionShape { + RegionOutlineArtwork( + path: regionPath, + tint: regionStyle.tint, + style: artwork.watermark, + ) + } + } + .blendMode(stylesheet.card.securityPrint.backgroundBlendMode) } .clipShape(shape) .allowsHitTesting(false) } - .glassEffect( - .regular.tint(regionStyle.tint.opacity(welcome.glassTintOpacity)), - in: shape, - ) .overlay { ZStack { shape.strokeBorder( @@ -111,10 +116,38 @@ struct RegionWelcomeCard: View { action: dismissAction, ) .labelStyle(.iconOnly) + .font(.system(size: 17, weight: .semibold)) .frame(width: 44, height: 44) - .background(.regularMaterial, in: Circle()) + .background { + Circle() + .fill(.background) + .opacity(welcome.paperOpacity) + } + .buttonStyle(.plain) + .glassEffect( + .regular.tint(regionStyle.tint.opacity(welcome.close.tintOpacity)) + .interactive(), + in: Circle(), + ) + .overlay { + Circle().strokeBorder( + regionStyle.tint.opacity(welcome.close.outlineOpacity), + lineWidth: welcome.outlineWidth, + ) + .allowsHitTesting(false) + } .contentShape(Circle()) - .offset(welcome.closeOffset) + .shadow( + color: regionStyle.tint.opacity(welcome.close.glow.opacity), + radius: welcome.close.glow.radius, + y: welcome.close.glow.offsetY, + ) + .shadow( + color: .black.opacity(welcome.close.lift.opacity), + radius: welcome.close.lift.radius, + y: welcome.close.lift.offsetY, + ) + .offset(welcome.close.offset) } .shadow( color: regionStyle.tint.opacity(welcome.shadowOpacity), diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 3e3709a3c..a1268c70a 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -109,7 +109,7 @@ extension WhereStylesheet { var cornerRadius: CGFloat var padding: CGFloat var contentSpacing: CGFloat - var artworkHeight: CGFloat + var paperOpacity: Double var scrimOpacity: Double var glassTintOpacity: Double var outlineOpacity: Double @@ -120,9 +120,23 @@ extension WhereStylesheet { var shadowOpacity: Double var shadowRadius: CGFloat var shadowOffsetY: CGFloat - var closeOffset: CGSize + var close: Close var motion: Motion + struct Close: Equatable { + var offset: CGSize + var tintOpacity: Double + var outlineOpacity: Double + var glow: Shadow + var lift: Shadow + + struct Shadow: Equatable { + var opacity: Double + var radius: CGFloat + var offsetY: CGFloat = 0 + } + } + struct Motion: Equatable { var animation: Animation var scale: CGFloat @@ -160,7 +174,7 @@ extension WhereStylesheet { cornerRadius: 30, padding: 24, contentSpacing: 16, - artworkHeight: 132, + paperOpacity: 0.92, scrimOpacity: 0.28, glassTintOpacity: 0.2, outlineOpacity: 0.28, @@ -171,7 +185,13 @@ extension WhereStylesheet { shadowOpacity: 0.42, shadowRadius: 30, shadowOffsetY: 16, - closeOffset: CGSize(width: 8, height: -8), + close: Close( + offset: CGSize(width: 8, height: -8), + tintOpacity: 0.24, + outlineOpacity: 0.38, + glow: .init(opacity: 0.28, radius: 8), + lift: .init(opacity: 0.22, radius: 5, offsetY: 3), + ), motion: .standard, ) } diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index fd9f0f403..d5ec22c5e 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -51,7 +51,7 @@ struct WhereStylesheetTests { #expect(welcome.cornerRadius == 30) #expect(welcome.padding == 24) #expect(welcome.contentSpacing == 16) - #expect(welcome.artworkHeight == 132) + #expect(welcome.paperOpacity == 0.92) #expect(welcome.scrimOpacity == 0.28) #expect(welcome.glassTintOpacity == 0.2) #expect(welcome.outlineOpacity == 0.28) @@ -62,7 +62,11 @@ struct WhereStylesheetTests { #expect(welcome.shadowOpacity == 0.42) #expect(welcome.shadowRadius == 30) #expect(welcome.shadowOffsetY == 16) - #expect(welcome.closeOffset == CGSize(width: 8, height: -8)) + #expect(welcome.close.offset == CGSize(width: 8, height: -8)) + #expect(welcome.close.tintOpacity == 0.24) + #expect(welcome.close.outlineOpacity == 0.38) + #expect(welcome.close.glow == .init(opacity: 0.28, radius: 8)) + #expect(welcome.close.lift == .init(opacity: 0.22, radius: 5, offsetY: 3)) #expect(welcome.motion == .standard) } From 7ee6cda8e6338b405764702ae9b48cdd4d3c56bd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 14:30:37 -0700 Subject: [PATCH 3/6] Add stay planning to region welcome --- .../locations.WelcomeBack_iPhone.png | 4 +- .../locations.WelcomeBack_iPhone_dark.png | 4 +- .../locations.WelcomeFirst_iPhone.png | 4 +- .../locations.WelcomeFirst_iPhone_ax5.png | 4 +- .../locations.WelcomeFirst_iPhone_dark.png | 4 +- .../Primary/LocationWelcomeOverlay.swift | 4 ++ .../Sources/Primary/LocationsView.swift | 14 +++++ .../Sources/Primary/RegionWelcomeCard.swift | 63 +++++++++++-------- .../Sources/Shared/WhereStylesheet.swift | 30 +++------ .../WhereUI/Tests/WhereStylesheetTests.swift | 9 +-- 10 files changed, 76 insertions(+), 64 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png index 5c73e1a29..ea6942d9d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d88d60c8ec4031e68d6c1a22d8b250ef917701c6ab0a0cc767cc2b1d65d04e0 -size 2207608 +oid sha256:877125defbc44e33f581cea1bfd05a4d57071e4208e8e636d8273a33d281b1a2 +size 2193220 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png index ad0ad869c..cd6c4cd96 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90e75a8e09e3f342731ab81fb042154db2c55a4c3034c168c6d592b17dad8202 -size 1968828 +oid sha256:7ed0241dcedcb7dbb683982a38a0407722ade297c520d8cb0ee97dae906580dc +size 1912429 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png index bb872960d..34af37afb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8fbb3a8826d18fbaa36344b71b402190739f48fb55cc01261c4386937ea2424e -size 2211357 +oid sha256:079199f80add607d959199ed9afac17d435e80b5f9fb9dfb1c473476f8586028 +size 2192038 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png index 4228d722b..544524e2b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efe7d10eac637e77834ed2519f8352ee707bd9dc37709e3b42b81c1ff2a3cb88 -size 3068507 +oid sha256:8609c8b68adf6cd1d46b1165332f60b4fd65d193a479fc27012a56a8c150e67a +size 2851933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png index e0e7b40bc..26829d174 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afe966bbd6dc20f3fe1382dc226a458f3f997cd578678250299e44013427621c -size 1969112 +oid sha256:f71e4cd44801faccabd8cd6628a2fc61f610da399a33e3dbd3288875cfc3afa7 +size 1911155 diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift index f6c7a0acb..7008e9095 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift @@ -1,3 +1,4 @@ +import RegionKit import SwiftUI import UIKit @@ -5,6 +6,7 @@ import UIKit struct LocationWelcomeOverlay: View { let presentation: LocationWelcomeModel.Presentation let dismissAction: () -> Void + let planStayAction: ((Region) -> Void)? @AccessibilityFocusState private var isCardFocused: Bool @Environment(\.stylesheet) private var stylesheet @@ -21,6 +23,7 @@ struct LocationWelcomeOverlay: View { RegionWelcomeCard( presentation: presentation, dismissAction: dismissAction, + planStayAction: planStayAction, ) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, stylesheet.spacing.xxxLarge) @@ -48,6 +51,7 @@ struct LocationWelcomeOverlay: View { LocationWelcomeOverlay( presentation: .init(region: .california, greeting: .first), dismissAction: {}, + planStayAction: { _ in }, ) .whereBroadwayRoot() } diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index a598e6bb7..a98e5c31a 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -47,6 +47,11 @@ struct LocationsView: View { && !planning.isShowingError } + private var welcomePlanStayAction: ((Region) -> Void)? { + guard report.showsEstimatedTimeAndPlanning else { return nil } + return planStayFromWelcome + } + init(report: YearReportModel) { self.init( report: report, @@ -105,6 +110,7 @@ struct LocationsView: View { LocationWelcomeOverlay( presentation: presentation, dismissAction: welcome.dismiss, + planStayAction: welcomePlanStayAction, ) .transition(stylesheet.locationWelcome.motion.transition) } @@ -293,6 +299,14 @@ struct LocationsView: View { plannedStayEditorTarget = PlannedStayEditorTarget(region: region) } + private func planStayFromWelcome(_ region: Region) { + withAnimation(stylesheet.locationWelcome.motion.animation) { + welcome.dismiss() + } completion: { + editPlannedStay(region) + } + } + private func clearPlannedStay() { Task { await planning.clear(using: report.forecasts.clear) diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift index ed4ad1528..367ad9f94 100644 --- a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -6,6 +6,7 @@ import SwiftUI struct RegionWelcomeCard: View { let presentation: LocationWelcomeModel.Presentation let dismissAction: () -> Void + let planStayAction: ((Region) -> Void)? @State private var regionPath = Path() @Environment(\.stylesheet) private var stylesheet @@ -31,7 +32,7 @@ struct RegionWelcomeCard: View { var body: some View { let shape = RoundedRectangle(cornerRadius: welcome.cornerRadius) - VStack(spacing: welcome.contentSpacing) { + VStack(alignment: .leading, spacing: welcome.contentSpacing) { HStack { Text(regionStyle.emoji) .font(.largeTitle) @@ -40,21 +41,35 @@ struct RegionWelcomeCard: View { PassportSeal(systemSymbol: regionStyle.symbol, tint: regionStyle.tint) } - VStack(spacing: stylesheet.spacing.medium) { + VStack(alignment: .leading, spacing: stylesheet.spacing.medium) { Text(title) .font(.title2.bold()) .fontDesign(.serif) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) Text(String(localized: .locationWelcomeMessage( presentation.region.localizedName, ))) .font(.body) .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) } .accessibilityElement(children: .combine) + + if planStayAction != nil { + Button( + String(localized: .locationForecastEditStay), + systemSymbol: .calendarBadgeClock, + action: planStay, + ) + .buttonStyle(LocationForecastEndorsementButtonStyle( + tint: regionStyle.tint, + expands: true, + controls: stylesheet.locationForecast.controls, + ink: stylesheet.locationForecast.ink, + )) + } } .padding(welcome.padding) .frame(maxWidth: welcome.maxWidth) @@ -94,19 +109,10 @@ struct RegionWelcomeCard: View { .allowsHitTesting(false) } .overlay { - ZStack { - shape.strokeBorder( - regionStyle.tint.opacity(welcome.outlineOpacity), - lineWidth: welcome.outlineWidth, - ) - shape.inset(by: welcome.inset).strokeBorder( - regionStyle.tint.opacity(welcome.outlineOpacity), - style: StrokeStyle( - lineWidth: welcome.outlineWidth, - dash: [welcome.insetDashLength, welcome.insetDashSpacing], - ), - ) - } + shape.strokeBorder( + regionStyle.tint.opacity(welcome.outlineOpacity), + lineWidth: welcome.outlineWidth, + ) .allowsHitTesting(false) } .overlay(alignment: .topTrailing) { @@ -129,13 +135,6 @@ struct RegionWelcomeCard: View { .interactive(), in: Circle(), ) - .overlay { - Circle().strokeBorder( - regionStyle.tint.opacity(welcome.close.outlineOpacity), - lineWidth: welcome.outlineWidth, - ) - .allowsHitTesting(false) - } .contentShape(Circle()) .shadow( color: regionStyle.tint.opacity(welcome.close.glow.opacity), @@ -150,9 +149,14 @@ struct RegionWelcomeCard: View { .offset(welcome.close.offset) } .shadow( - color: regionStyle.tint.opacity(welcome.shadowOpacity), - radius: welcome.shadowRadius, - y: welcome.shadowOffsetY, + color: regionStyle.tint.opacity(welcome.glow.opacity), + radius: welcome.glow.radius, + y: welcome.glow.offsetY, + ) + .shadow( + color: .black.opacity(welcome.lift.opacity), + radius: welcome.lift.radius, + y: welcome.lift.offsetY, ) .task(id: presentation.region) { guard let regionOutlinePathCache else { return } @@ -164,6 +168,10 @@ struct RegionWelcomeCard: View { regionPath = loaded } } + + private func planStay() { + planStayAction?(presentation.region) + } } #if DEBUG @@ -171,6 +179,7 @@ struct RegionWelcomeCard: View { RegionWelcomeCard( presentation: .init(region: .california, greeting: .returnVisit), dismissAction: {}, + planStayAction: { _ in }, ) .padding(32) .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index a1268c70a..42066900c 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -114,27 +114,22 @@ extension WhereStylesheet { var glassTintOpacity: Double var outlineOpacity: Double var outlineWidth: CGFloat - var inset: CGFloat - var insetDashLength: CGFloat - var insetDashSpacing: CGFloat - var shadowOpacity: Double - var shadowRadius: CGFloat - var shadowOffsetY: CGFloat + var glow: Shadow + var lift: Shadow var close: Close var motion: Motion + struct Shadow: Equatable { + var opacity: Double + var radius: CGFloat + var offsetY: CGFloat = 0 + } + struct Close: Equatable { var offset: CGSize var tintOpacity: Double - var outlineOpacity: Double var glow: Shadow var lift: Shadow - - struct Shadow: Equatable { - var opacity: Double - var radius: CGFloat - var offsetY: CGFloat = 0 - } } struct Motion: Equatable { @@ -179,16 +174,11 @@ extension WhereStylesheet { glassTintOpacity: 0.2, outlineOpacity: 0.28, outlineWidth: 1, - inset: 9, - insetDashLength: 5, - insetDashSpacing: 4, - shadowOpacity: 0.42, - shadowRadius: 30, - shadowOffsetY: 16, + glow: Shadow(opacity: 0.16, radius: 22), + lift: Shadow(opacity: 0.18, radius: 12, offsetY: 6), close: Close( offset: CGSize(width: 8, height: -8), tintOpacity: 0.24, - outlineOpacity: 0.38, glow: .init(opacity: 0.28, radius: 8), lift: .init(opacity: 0.22, radius: 5, offsetY: 3), ), diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index d5ec22c5e..aed0944be 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -56,15 +56,10 @@ struct WhereStylesheetTests { #expect(welcome.glassTintOpacity == 0.2) #expect(welcome.outlineOpacity == 0.28) #expect(welcome.outlineWidth == 1) - #expect(welcome.inset == 9) - #expect(welcome.insetDashLength == 5) - #expect(welcome.insetDashSpacing == 4) - #expect(welcome.shadowOpacity == 0.42) - #expect(welcome.shadowRadius == 30) - #expect(welcome.shadowOffsetY == 16) + #expect(welcome.glow == .init(opacity: 0.16, radius: 22)) + #expect(welcome.lift == .init(opacity: 0.18, radius: 12, offsetY: 6)) #expect(welcome.close.offset == CGSize(width: 8, height: -8)) #expect(welcome.close.tintOpacity == 0.24) - #expect(welcome.close.outlineOpacity == 0.38) #expect(welcome.close.glow == .init(opacity: 0.28, radius: 8)) #expect(welcome.close.lift == .init(opacity: 0.22, radius: 5, offsetY: 3)) #expect(welcome.motion == .standard) From be75a3bed1360fcde29008050a4a505ad50b8a7b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 17:45:25 -0700 Subject: [PATCH 4/6] Remove welcome card border --- .../locations.WelcomeBack_iPhone.png | 4 ++-- .../locations.WelcomeBack_iPhone_dark.png | 4 ++-- .../locations.WelcomeFirst_iPhone.png | 4 ++-- .../locations.WelcomeFirst_iPhone_ax5.png | 4 ++-- .../locations.WelcomeFirst_iPhone_dark.png | 4 ++-- Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift | 7 ------- Where/WhereUI/Sources/Shared/WhereStylesheet.swift | 4 ---- Where/WhereUI/Tests/WhereStylesheetTests.swift | 2 -- 8 files changed, 10 insertions(+), 23 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png index ea6942d9d..193cc2a06 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:877125defbc44e33f581cea1bfd05a4d57071e4208e8e636d8273a33d281b1a2 -size 2193220 +oid sha256:c03b8dfc4cf9329ff77d6c9683ee7b330aecdcc3ac9a8e190ebe8b99dfef6f7b +size 2065271 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png index cd6c4cd96..3d68ad63b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ed0241dcedcb7dbb683982a38a0407722ade297c520d8cb0ee97dae906580dc -size 1912429 +oid sha256:e976d0c8964ddab26fa7d38a1504608ffdde851617992720d1ce7cea0c4db320 +size 1866260 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png index 34af37afb..81a13c293 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:079199f80add607d959199ed9afac17d435e80b5f9fb9dfb1c473476f8586028 -size 2192038 +oid sha256:08e0e00f978939bba9c3d4a0e32dc5cfd9cdc0531f6509513b3a83ab5b637909 +size 2064619 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png index 544524e2b..be4b1caea 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8609c8b68adf6cd1d46b1165332f60b4fd65d193a479fc27012a56a8c150e67a -size 2851933 +oid sha256:5859039efcd65b8a23e6375d18c0ced5ddaa9644e8fab6b31a5d76cc274e86e3 +size 2579304 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png index 26829d174..1253a6ec6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f71e4cd44801faccabd8cd6628a2fc61f610da399a33e3dbd3288875cfc3afa7 -size 1911155 +oid sha256:dfc0d423a4113bca33f5c8938d0322e9764e4ee9da41b772fb5b1015136f5837 +size 1864855 diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift index 367ad9f94..362a75471 100644 --- a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -108,13 +108,6 @@ struct RegionWelcomeCard: View { .clipShape(shape) .allowsHitTesting(false) } - .overlay { - shape.strokeBorder( - regionStyle.tint.opacity(welcome.outlineOpacity), - lineWidth: welcome.outlineWidth, - ) - .allowsHitTesting(false) - } .overlay(alignment: .topTrailing) { Button( String(localized: .locationWelcomeDismiss), diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 42066900c..638eb218e 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -112,8 +112,6 @@ extension WhereStylesheet { var paperOpacity: Double var scrimOpacity: Double var glassTintOpacity: Double - var outlineOpacity: Double - var outlineWidth: CGFloat var glow: Shadow var lift: Shadow var close: Close @@ -172,8 +170,6 @@ extension WhereStylesheet { paperOpacity: 0.92, scrimOpacity: 0.28, glassTintOpacity: 0.2, - outlineOpacity: 0.28, - outlineWidth: 1, glow: Shadow(opacity: 0.16, radius: 22), lift: Shadow(opacity: 0.18, radius: 12, offsetY: 6), close: Close( diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index aed0944be..208297c6d 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -54,8 +54,6 @@ struct WhereStylesheetTests { #expect(welcome.paperOpacity == 0.92) #expect(welcome.scrimOpacity == 0.28) #expect(welcome.glassTintOpacity == 0.2) - #expect(welcome.outlineOpacity == 0.28) - #expect(welcome.outlineWidth == 1) #expect(welcome.glow == .init(opacity: 0.16, radius: 22)) #expect(welcome.lift == .init(opacity: 0.18, radius: 12, offsetY: 6)) #expect(welcome.close.offset == CGSize(width: 8, height: -8)) From 100804c2b08419de1f282bbd301f42a62c53c39e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 17:54:43 -0700 Subject: [PATCH 5/6] Add region microprint to welcome card --- .../locations.WelcomeBack_iPhone.png | 4 +- .../locations.WelcomeBack_iPhone_dark.png | 4 +- .../locations.WelcomeFirst_iPhone.png | 4 +- .../locations.WelcomeFirst_iPhone_ax5.png | 4 +- .../locations.WelcomeFirst_iPhone_dark.png | 4 +- .../Sources/Primary/RegionWelcomeCard.swift | 47 ++++++++++++++++--- 6 files changed, 51 insertions(+), 16 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png index 193cc2a06..c4c5edf45 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c03b8dfc4cf9329ff77d6c9683ee7b330aecdcc3ac9a8e190ebe8b99dfef6f7b -size 2065271 +oid sha256:086ebe4b5fe0fe9583c3e39e8b3388203578c688667ef9dc954f444fe9d79548 +size 2087274 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png index 3d68ad63b..732b1e7eb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e976d0c8964ddab26fa7d38a1504608ffdde851617992720d1ce7cea0c4db320 -size 1866260 +oid sha256:8954f604794f1b1a5bdb2ccc6a83e58c6e78e67889dc9b602608deb347f6287b +size 1886913 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png index 81a13c293..dacfdbd69 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08e0e00f978939bba9c3d4a0e32dc5cfd9cdc0531f6509513b3a83ab5b637909 -size 2064619 +oid sha256:015b7cfa55bbe9686c33995736e3cc5b49638ef7e269f1bd7b4b7017bba0af06 +size 2085984 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png index be4b1caea..1cac1600e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5859039efcd65b8a23e6375d18c0ced5ddaa9644e8fab6b31a5d76cc274e86e3 -size 2579304 +oid sha256:75f6fff18af889005c96612bb885199257bf571a5ce03b89e944c469a05e9386 +size 2621114 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png index 1253a6ec6..5374758e9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfc0d423a4113bca33f5c8938d0322e9764e4ee9da41b772fb5b1015136f5837 -size 1864855 +oid sha256:8ed3d5c73b6ac4950417e6f6d33dd789334a7efbb46fa995db31b2b7502bdc7c +size 1885876 diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift index 362a75471..b44485a9c 100644 --- a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -8,7 +8,7 @@ struct RegionWelcomeCard: View { let dismissAction: () -> Void let planStayAction: ((Region) -> Void)? - @State private var regionPath = Path() + @State private var regionPaths: ArtworkPaths? @Environment(\.stylesheet) private var stylesheet @Environment(\.regionStyles) private var regionStyles @Environment(\.regionOutlinePathCache) private var regionOutlinePathCache @@ -95,9 +95,29 @@ struct RegionWelcomeCard: View { secondaryOpacity: stylesheet.card.rosetteFill.secondary, ) - if let artwork = stylesheet.card.regular.regionShape { + if + let regionShape = stylesheet.card.regular.regionShape, + let microprint = regionPaths?.microprint, + !microprint.isEmpty + { + RegionOutlineSecurityBorder( + paths: [microprint], + tint: regionStyle.tint, + cornerRadius: welcome.cornerRadius, + inset: regionShape.securityBorder.inset, + glyphSize: regionShape.securityBorder.glyphSize, + spacing: regionShape.securityBorder.spacing, + opacity: regionShape.securityBorder.opacity, + ) + } + + if + let artwork = stylesheet.card.regular.regionShape, + let watermark = regionPaths?.watermark, + !watermark.isEmpty + { RegionOutlineArtwork( - path: regionPath, + path: watermark, tint: regionStyle.tint, style: artwork.watermark, ) @@ -153,18 +173,33 @@ struct RegionWelcomeCard: View { ) .task(id: presentation.region) { guard let regionOutlinePathCache else { return } - let loaded = await regionOutlinePathCache.path( - for: presentation.region, + let region = presentation.region + async let watermark = regionOutlinePathCache.path( + for: region, resolution: .medium, ) + async let microprint = regionOutlinePathCache.path( + for: region, + resolution: .micro, + ) + let (watermarkPath, microprintPath) = await (watermark, microprint) + let loaded = ArtworkPaths( + watermark: watermarkPath, + microprint: microprintPath, + ) guard !Task.isCancelled else { return } - regionPath = loaded + regionPaths = loaded } } private func planStay() { planStayAction?(presentation.region) } + + private struct ArtworkPaths { + let watermark: Path + let microprint: Path + } } #if DEBUG From 1e3227994a2177a476cfff773d002eaebb4d5064 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 18:05:32 -0700 Subject: [PATCH 6/6] Add welcome card appearance setting --- Where/WhereCore/README.md | 4 +-- .../Preferences/WherePreferences.swift | 8 +++++ .../Tests/WherePreferencesTests.swift | 11 ++++++ Where/WhereUI/README.md | 2 +- .../appearance.Default_iPad.png | 4 +-- .../appearance.Default_iPad_accessibility.png | 4 +-- .../appearance.Default_iPad_ax5.png | 4 +-- .../appearance.Default_iPad_contrast.png | 4 +-- .../appearance.Default_iPad_dark.png | 4 +-- .../appearance.Default_iPhone.png | 4 +-- ...ppearance.Default_iPhone_accessibility.png | 4 +-- .../appearance.Default_iPhone_ax5.png | 4 +-- .../appearance.Default_iPhone_contrast.png | 4 +-- .../appearance.Default_iPhone_dark.png | 4 +-- .../Sources/Model/YearReportModel.swift | 16 +++++++++ .../Primary/LocationWelcomeModel.swift | 9 +++-- .../Sources/Primary/LocationsView.swift | 16 ++++++--- .../Sources/Resources/Localizable.xcstrings | 36 +++++++++++++++++++ .../Settings/AppearanceSettingsView.swift | 21 +++++++++-- .../Tests/LocationWelcomeModelTests.swift | 29 +++++++++++++++ Where/WhereUI/Tests/SettingsSearchTests.swift | 9 +++++ .../WhereUI/Tests/YearReportModelTests.swift | 15 ++++++++ 22 files changed, 184 insertions(+), 32 deletions(-) diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 27085b223..76b8e2fbc 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -175,8 +175,8 @@ one it belongs to rather than to a god-object: `InstallationRecordingContextStoring` keeps the persistence adapter outside the domain value. - **`WherePreferences`** — persisted user intent (onboarding, - reminder / summary schedules, presentation theme, and Locations-card GPS-dot and - estimated-time/planning visibility) plus the + reminder / summary schedules, presentation theme, and Locations-card GPS-dot, + live-region welcome, and estimated-time/planning visibility) plus the year-keyed Location-card counts, last welcomed region, and recording-warning generation used for presentation continuity, behind a `KeyValueStore`. It also owns the vendor-neutral `DiagnosticReportingConfiguration`: crash reports default On, replay Off, diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index 7e7136e9e..fe822f32c 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -49,6 +49,13 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.showsRecordedLocationDots.rawValue) } } + /// Whether Locations may present the live-region welcome card. Defaults to + /// `true` so the acknowledgement remains available until explicitly hidden. + public var showsLocationWelcome: Bool { + get { store.object(forKey: Keys.showsLocationWelcome.rawValue) as? Bool ?? true } + set { store.set(newValue, forKey: Keys.showsLocationWelcome.rawValue) } + } + /// The device-local presentation theme. Missing and unrecognized values /// resolve to Standard so upgrades preserve the app's familiar appearance. public var theme: WhereTheme { @@ -255,6 +262,7 @@ public final class WherePreferences { private enum Keys: String, CaseIterable { case hasOnboarded = "where.hasOnboarded" case showsRecordedLocationDots = "where.showsRecordedLocationDots" + case showsLocationWelcome = "where.showsLocationWelcome" case theme = "where.theme" case showsLocationForecastsOnLocationsTab = "where.showsLocationForecastsOnLocationsTab" case remindersEnabled = "where.remindersEnabled" diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index 151152529..1d5555789 100644 --- a/Where/WhereCore/Tests/WherePreferencesTests.swift +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -13,6 +13,7 @@ struct WherePreferencesTests { #expect(preferences.hasOnboarded == false) #expect(preferences.showsRecordedLocationDots) + #expect(preferences.showsLocationWelcome) #expect(preferences.theme == .standard) #expect(preferences.showsEstimatedTimeAndPlanning) #expect(preferences.remindersEnabled) @@ -138,6 +139,14 @@ struct WherePreferencesTests { #expect(preferences.lastWelcomedRegion == nil) } + @Test func locationWelcomeVisibilityRoundTrips() { + let preferences = preferences() + + preferences.showsLocationWelcome = false + + #expect(preferences.showsLocationWelcome == false) + } + @Test func estimatedTimeUsesTheLegacyLocationsVisibilityKey() { let store = InMemoryKeyValueStore() store.set(false, forKey: "where.showsLocationForecastsOnLocationsTab") @@ -191,6 +200,7 @@ struct WherePreferencesTests { let preferences = preferences() preferences.hasOnboarded = true preferences.showsRecordedLocationDots = false + preferences.showsLocationWelcome = false preferences.theme = .alternate preferences.showsEstimatedTimeAndPlanning = false preferences.remindersEnabled = false @@ -218,6 +228,7 @@ struct WherePreferencesTests { #expect(preferences.hasOnboarded == false) #expect(preferences.showsRecordedLocationDots) + #expect(preferences.showsLocationWelcome) #expect(preferences.theme == .standard) #expect(preferences.showsEstimatedTimeAndPlanning) #expect(preferences.remindersEnabled) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4290e6458..2da87ad72 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -120,7 +120,7 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's demo, and completion orchestration) and **`OnboardingImportRecoveryModel`** (the sidecar/store recovery handshake after an interrupted onboarding import), and **`LocationCardsPresentationModel`** (the last primary-card counts and order - the user saw), and **`LocationWelcomeModel`** (the current-region welcome and + the user saw), and **`LocationWelcomeModel`** (the preference-gated current-region welcome and its persisted acknowledgement). The Location model holds saved values until the card surface is visible and unobscured, holds them there for another half second, then advances every changed number and any live two-card reversal in one animated diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png index 4eaa1e9f7..15ae7d909 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05aeb9b9b5989fad5e732d76700a180ea3e355508783e5353c86aa427491efc7 -size 1266390 +oid sha256:d8c404376e9f4021d86df9a49e287878ea43920e9145a6fcd0d5c070cf2b6953 +size 1299833 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png index 124037f60..87a8f7b0c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f59a33fdafaca34e09a4b01efbe1107b42daf16b255524b2b97b29b35ed83b2 -size 1226794 +oid sha256:7f7a172148bd9ee8d74e220719127661c8dd20722a5ac39d3b814ab245644c4b +size 1306081 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png index a76f6fd49..19bd1505d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bd46157b83a5c792bad30423b1fbd51a99621a3aae80e8a78b4231961be4fd5 -size 3409730 +oid sha256:13196d4dd04ea3a7b40a0b391b2757311580ed422853ffcd5e5fd01296145722 +size 3539335 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png index 4efe42f68..0e6443421 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41cec6bc4760f3908133e092bc13badd0ebac992e2ca03058f01163397ac0661 -size 1329174 +oid sha256:b2811b25e511d33474cfbb945e5f324d4c717087c0c95b0f66313da66ef840b4 +size 1363243 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png index 5f567556f..cf539f78b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:48a8798cfdbbe072b2fffdd36593a30ccc437fad08e760056f544a96cfc3c42a -size 1507656 +oid sha256:d3a72192d2c152ce44c254f40af253bf38b1b659ca88fea9c293351652e9abf7 +size 1541919 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png index d27b9f232..5dd12dadf 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1fcbf2d3652b3ca891e6230d9b87bd4bd55e378ea8bfa602cc36b562957c1f13 -size 845476 +oid sha256:67a17a855e40ee123106eb324a032517ebc547450d86b2628c7c960cad2d0611 +size 837739 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png index 5b2bf0d65..e01c06e8b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd082a88961a7e9118fcffc9304446bd8196ac2c9aa04ef381aa7c301973c806 -size 953698 +oid sha256:0ab865fb220c599429ba1ea42badc20ec8f4cf47d03207af52c50e94caa9c7d5 +size 1024509 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png index 869fa1f23..a00e187a6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ff9a054f57286c0be114c3e7f18873bc62d7e0557a1bd8c58b6e7e2d985a95f -size 2334670 +oid sha256:7c09cc7eb5dbabc1120554df61ec356d233f0c2a531a0112799a27ac167916ef +size 2435957 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png index 5ccd21bc6..9a856d12a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:023793fc7ca23b9dd4157ed08c867cf928520d971dd6230f28e8b736384e9886 -size 897068 +oid sha256:f3d5091d586f5e1511afa22bfec29dcf3553458297c85f662286d09cadf05d26 +size 883379 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png index 6e353863a..383c358e6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9340086e57e79f3da8ea4d20ba8997d7ce0736af9c54b6d99d87f1587b382fc9 -size 940008 +oid sha256:dd8c66be2db4a82f647556484dfb7c90a865addf7143c443c1870495805851c1 +size 927430 diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 08e6a20d5..17eb77a26 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -150,6 +150,10 @@ public final class YearReportModel { /// model publishes this value to both the Appearance toggle and Locations. private var showsRecordedLocationDotsStorage: Bool + /// Observed mirror of the live-region welcome visibility preference. The + /// Appearance toggle and mounted Locations root share this scene state. + private var showsLocationWelcomeStorage: Bool + /// Observed mirror of the estimated-time and planning visibility preference. /// `WherePreferences` is intentionally not observable, so the async intent /// below updates every mounted forecast/planning surface immediately. @@ -181,6 +185,17 @@ public final class YearReportModel { } } + /// Whether Locations may resolve and present its live-region welcome. + /// Writes persist synchronously and hide a mounted welcome immediately. + var showsLocationWelcome: Bool { + get { showsLocationWelcomeStorage } + set { + guard newValue != showsLocationWelcomeStorage else { return } + showsLocationWelcomeStorage = newValue + preferences.showsLocationWelcome = newValue + } + } + /// GPS border-drift detection threshold (device setting). The setter persists /// it, forces a badge recount, and — through the observed mirror — re-keys /// `dataIssueScanInputs` so the Resolve list re-scans immediately, not just on @@ -272,6 +287,7 @@ public final class YearReportModel { driftThresholdStorage = DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default showsRecordedLocationDotsStorage = preferences.showsRecordedLocationDots + showsLocationWelcomeStorage = preferences.showsLocationWelcome showsEstimatedTimeAndPlanning = preferences.showsEstimatedTimeAndPlanning var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift index b0864a46b..1f955d081 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift @@ -29,13 +29,18 @@ public final class LocationWelcomeModel { /// Resolves a fresh welcome while the Locations root is visible. func resolve() async { - guard presentation == nil else { return } + guard preferences.showsLocationWelcome, presentation == nil else { return } let (sequence, overflow) = resolutionSequence.addingReportingOverflow(1) precondition(!overflow, "Location welcome resolution sequence exhausted UInt64.") resolutionSequence = sequence guard let region = await resolver.resolve() else { return } - guard !Task.isCancelled, sequence == resolutionSequence, presentation == nil else { return } + guard + !Task.isCancelled, + preferences.showsLocationWelcome, + sequence == resolutionSequence, + presentation == nil + else { return } let previous = preferences.lastWelcomedRegion guard region != previous else { return } presentation = Presentation( diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index a98e5c31a..b5cb218bf 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -37,16 +37,22 @@ struct LocationsView: View { && !showingResolution && plannedStayEditorTarget == nil && !planning.isShowingError - && welcome.presentation == nil + && welcomePresentation == nil } private var isWelcomeLookupActive: Bool { - isLocationsSurfaceVisible + report.showsLocationWelcome + && isLocationsSurfaceVisible && !showingResolution && plannedStayEditorTarget == nil && !planning.isShowingError } + private var welcomePresentation: LocationWelcomeModel.Presentation? { + guard report.showsLocationWelcome else { return nil } + return welcome.presentation + } + private var welcomePlanStayAction: ((Region) -> Void)? { guard report.showsEstimatedTimeAndPlanning else { return nil } return planStayFromWelcome @@ -104,9 +110,9 @@ struct LocationsView: View { } } } - .accessibilityHidden(welcome.presentation != nil) + .accessibilityHidden(welcomePresentation != nil) .overlay { - if let presentation = welcome.presentation { + if let presentation = welcomePresentation { LocationWelcomeOverlay( presentation: presentation, dismissAction: welcome.dismiss, @@ -115,7 +121,7 @@ struct LocationsView: View { .transition(stylesheet.locationWelcome.motion.transition) } } - .animation(stylesheet.locationWelcome.motion.animation, value: welcome.presentation) + .animation(stylesheet.locationWelcome.motion.animation, value: welcomePresentation) .task(id: isWelcomeLookupActive) { guard isWelcomeLookupActive else { return } await welcome.resolve() diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 596153f2f..fb4a9878f 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -6149,6 +6149,30 @@ } } }, + "settings.appearance.locationWelcome.footer" : { + "comment" : "Explains when the live-region welcome card appears.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show a welcome card when Where starts counting days toward a different tracked region." + } + } + } + }, + "settings.appearance.locationWelcome.toggle" : { + "comment" : "Toggle that controls whether live-region welcome cards appear on the Locations tab.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Region welcome cards" + } + } + } + }, "settings.appearance.theme.footer" : { "extractionState" : "manual", "localizations" : { @@ -8542,6 +8566,18 @@ } } }, + "settings.keywords.locationWelcome" : { + "comment" : "Comma-separated Settings search keywords for the live-region welcome visibility toggle.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "welcome, greeting, region, location, card, arrival" + } + } + } + }, "settings.keywords.loggedDays" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index f81d63673..5e082bdc7 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -3,8 +3,8 @@ import SnapshotKit import SwiftUI import WhereCore -/// Settings drill-in for presentation choices: theme, Locations-card overlays, -/// and alternate app icon. +/// Settings drill-in for presentation choices: theme, Locations-card overlays +/// and welcomes, and alternate app icon. struct AppearanceSettingsView: View { let report: YearReportModel var focus: SettingsFocus? @@ -52,6 +52,18 @@ struct AppearanceSettingsView: View { Text(String(localized: .settingsAppearanceLocationDotsFooter)) } + Section { + Toggle(isOn: $report.showsLocationWelcome) { + Label( + String(localized: .settingsAppearanceLocationWelcomeToggle), + systemSymbol: .sparkles, + ) + } + .settingsRow(Item.locationWelcome) + } footer: { + Text(String(localized: .settingsAppearanceLocationWelcomeFooter)) + } + Section { Toggle(isOn: $estimatedTimeSettings.isEnabled) { HStack { @@ -141,6 +153,7 @@ extension AppearanceSettingsView: SettingsSection { enum Item: SettingsItem { case theme case locationDots + case locationWelcome case locationForecasts case appIcon #if DEBUG @@ -153,6 +166,8 @@ extension AppearanceSettingsView: SettingsSection { case .theme: String(localized: .settingsAppearanceThemeHeader) case .locationDots: String(localized: .settingsAppearanceLocationDotsToggle) + case .locationWelcome: + String(localized: .settingsAppearanceLocationWelcomeToggle) case .locationForecasts: String(localized: .settingsAppearanceLocationForecastsToggle) case .appIcon: String(localized: .settingsAppIconLink) @@ -169,6 +184,8 @@ extension AppearanceSettingsView: SettingsSection { splitKeywords(String(localized: .settingsKeywordsTheme)) case .locationDots: splitKeywords(String(localized: .settingsKeywordsLocationDots)) + case .locationWelcome: + splitKeywords(String(localized: .settingsKeywordsLocationWelcome)) case .locationForecasts: splitKeywords(String(localized: .settingsKeywordsLocationForecasts)) case .appIcon: splitKeywords(String(localized: .settingsKeywordsAppIcon)) diff --git a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift index 770fe2f5c..aabf3e855 100644 --- a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift +++ b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift @@ -46,6 +46,35 @@ struct LocationWelcomeModelTests { #expect(fixture.model.presentation == nil) } + @Test func disabledPreferenceDoesNotPresent() async throws { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.showsLocationWelcome = false + let fixture = try await fixture(region: .california, preferences: preferences) + + await fixture.model.resolve() + + #expect(fixture.model.presentation == nil) + } + + @Test func disabledDuringResolutionDoesNotPublishALateWelcome() async throws { + let source = GatedCurrentLocationSource() + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try Self.services(locationSource: source) + try await services.ingestor.authorizeRecording() + let model = LocationWelcomeModel(services: services, preferences: preferences) + let task = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + + preferences.showsLocationWelcome = false + try await source.resolveRequest( + at: 0, + with: Self.sample(region: .california), + ) + await task.value + + #expect(model.presentation == nil) + } + @Test func cancelledResolutionDoesNotPublishALateRegion() async throws { let source = GatedCurrentLocationSource() let preferences = WherePreferences(store: InMemoryKeyValueStore()) diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index d89b514ad..724f6ffd4 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -59,6 +59,15 @@ struct SettingsSearchTests { }) } + @Test func matchesLocationWelcomeVisibilityOnGreetingKeyword() { + let results = SettingsCatalog.results(matching: "greeting") + + #expect(results.contains { + $0.destination == .appearance + && $0.title == String(localized: .settingsAppearanceLocationWelcomeToggle) + }) + } + @Test func matchesTheRankingAnimationLabOnMotionKeyword() { let results = SettingsCatalog.results(matching: "overtake") diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index cbb74df44..2fd0c3597 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -294,6 +294,21 @@ struct YearReportModelTests { #expect(preferences.showsEstimatedTimeAndPlanning) } + @Test func locationWelcomeVisibilityMirrorsAndPersists() throws { + let preferences = makePreferences() + preferences.showsLocationWelcome = false + let report = try YearReportModel( + services: makeServices(), + selectedYear: 2026, + preferences: preferences, + ) + + #expect(report.showsLocationWelcome == false) + + report.showsLocationWelcome = true + #expect(preferences.showsLocationWelcome) + } + /// The Resolve list keys its scan `.task(id:)` on `dataIssueScanInputs`, so a /// drift-threshold change must change that identity — otherwise the list keeps /// a stale scan while the badge count moves and the two visibly disagree. The