From 9d1fd23730c2e73178de0c007b9a0bd283c47e22 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 13 Sep 2026 19:59:37 -0400 Subject: [PATCH 01/11] Resolve region welcomes on foreground activation --- Where/AGENTS.md | 7 +- Where/TODOs.md | 3 +- Where/WhereCore/AGENTS.md | 5 +- Where/WhereCore/README.md | 15 +- .../PlannedStayLocationVerifier.swift | 4 +- .../Sources/Location/CoreLocationSource.swift | 193 ++++++++++++++--- .../Location/CurrentLocationResult.swift | 22 ++ .../Location/CurrentRegionResolution.swift | 18 ++ .../Location/CurrentRegionResolver.swift | 121 ++++++++++- .../Sources/Location/IdleLocationSource.swift | 8 +- .../Sources/Location/LocationIngestor.swift | 13 +- .../Sources/Location/LocationSource.swift | 37 ++-- .../Logging/CurrentRegionResolverLog.swift | 81 ++++++++ Where/WhereCore/Sources/WhereServices.swift | 2 +- .../Tests/CoreLocationSourceTests.swift | 128 ++++++++++++ .../Tests/CurrentRegionResolverTests.swift | 195 ++++++++++++++---- .../Tests/IdleLocationSourceTests.swift | 5 +- .../Tests/LocationIngestorTests.swift | 64 +++++- .../CurrentRegionResolverLogTests.swift | 25 +++ .../Tests/WhereServices+IntentsTests.swift | 10 +- Where/WhereUI/AGENTS.md | 4 + Where/WhereUI/README.md | 15 +- .../SnapshotTests/MainTabsSnapshotTests.swift | 10 + .../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 - .../mainTabs.WelcomeActionRequired_iPhone.png | 3 + ...omeActionRequired_iPhone_accessibility.png | 3 + ...nTabs.WelcomeActionRequired_iPhone_ax5.png | 3 + ...Tabs.WelcomeActionRequired_iPhone_dark.png | 3 + .../mainTabs.WelcomeLocating_iPhone.png | 3 + ...s.WelcomeLocating_iPhone_accessibility.png | 3 + .../mainTabs.WelcomeLocating_iPhone_ax5.png | 3 + .../mainTabs.WelcomeLocating_iPhone_dark.png | 3 + .../mainTabs.WelcomeLocations_iPhone.png | 3 + ....WelcomeLocations_iPhone_accessibility.png | 3 + .../mainTabs.WelcomeLocations_iPhone_ax5.png | 3 + .../mainTabs.WelcomeLocations_iPhone_dark.png | 3 + .../mainTabs.WelcomeYear_iPhone.png | 3 + ...nTabs.WelcomeYear_iPhone_accessibility.png | 3 + .../mainTabs.WelcomeYear_iPhone_ax5.png | 3 + .../mainTabs.WelcomeYear_iPhone_dark.png | 3 + Where/WhereUI/Sources/MainTabs.swift | 186 +++++++++++++++++ .../Sources/Model/YearReportModel.swift | 3 +- .../Primary/LocationWelcomeModel.swift | 166 +++++++++++++-- .../Primary/LocationWelcomeOverlay.swift | 2 +- .../LocationWelcomeStatusAccessory.swift | 81 ++++++++ .../Sources/Primary/LocationsView.swift | 82 -------- .../Sources/Resources/Localizable.xcstrings | 36 ++++ .../Sources/Shared/WhereStylesheet.swift | 16 ++ .../Tests/LocationWelcomeModelTests.swift | 129 +++++++++++- .../Support/GatedCurrentLocationSource.swift | 11 +- .../Tests/WhereSessionTrackingTests.swift | 8 +- .../WhereUI/Tests/WhereStylesheetTests.swift | 5 + 56 files changed, 1516 insertions(+), 257 deletions(-) create mode 100644 Where/WhereCore/Sources/Location/CurrentLocationResult.swift create mode 100644 Where/WhereCore/Sources/Location/CurrentRegionResolution.swift create mode 100644 Where/WhereCore/Sources/Logging/CurrentRegionResolverLog.swift create mode 100644 Where/WhereCore/Tests/CoreLocationSourceTests.swift create mode 100644 Where/WhereCore/Tests/Logging/CurrentRegionResolverLogTests.swift create mode 100644 Where/WhereUI/SnapshotTests/MainTabsSnapshotTests.swift delete mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png delete mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png delete mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png delete mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png delete mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png create mode 100644 Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png create mode 100644 Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift diff --git a/Where/AGENTS.md b/Where/AGENTS.md index dd88e42a9..257717694 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -64,8 +64,11 @@ Rules the code enforces and agents must preserve: [Spans](#spans). - **Location comes through the `LocationSource` protocol.** `CoreLocationSource` runs in production. `ScriptedLocationSource` runs in - tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather - than throwing when no fix is available. + tests/previews. The bounded one-shot `requestCurrentLocation()` returns a + typed, nonthrowing acquisition outcome. Live region decisions require a fix + no more than 60 seconds old, with valid accuracy no worse than 1 km, whose + uncertainty circle stays inside one tracked region. Passive valid samples + remain historical evidence regardless of that live-decision cap. - **Automatic recording consent is installation-local.** Stamp automatic GPS samples with their `RecordingDeviceID`. Route user-facing reads through `LocationHistoryReader`. Sync profiles, nickname events, advisory check-ins, diff --git a/Where/TODOs.md b/Where/TODOs.md index cf55342ab..c375572e7 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -21,7 +21,6 @@ The item format and the placement rule live in the root - perf(WhereCore) [needs-design]: Measure automatic launch and GPS-write frequency before changing the movement threshold — `LocationIngestor` owns passive sample admission and the explicit foreground one-shot (`WhereCore/Sources/Location/LocationIngestor.swift:347-367`); launch spans are declared in `WhereUI/Sources/Launch/WhereLaunchSteps.swift:177-210`. Use those diagnostics to establish boot/write rates, then decide whether distance filtering can reduce work without losing day boundaries or region crossings. A 1 km threshold remains a human proposal, not a measured requirement. (human) ## P1s (Should do) -- fix(WhereUI) [quick-win]: Refresh the live-region welcome when the scene becomes active — the only lookup is `.task(id: isWelcomeLookupActive)` (`Primary/LocationsView.swift:43-49`, `:122-125`), keyed by view visibility and covering UI, with no `scenePhase` input. `MainTabs` refreshes only the report on foreground (`Sources/MainTabs.swift:88-97`). After dismissing a region, backgrounding, travelling, and resuming with the Locations hierarchy retained, no input changes to request a new region; a prior unavailable fix likewise waits for a tab or covering-UI change. Key the visible lookup by scene activity and cancel it on background; add a regression for foreground re-entry after a nil or previously acknowledged result. This is a static lifecycle gap; reproduce the retained-tab path on a device before choosing the final trigger. (audit 2026-09-07, PR #309) - fix(WhereCore) [needs-design]: Scope initial CloudKit-import readiness to Where's expected store/container. `CloudKitImportReadiness.start()` observes `NSPersistentCloudKitContainer.eventChangedNotification` with `object: nil` (`WhereCore/Sources/Persistence/CloudKitImportReadiness.swift:19-26`), and `eventChanged(_:)` accepts any successful completed import (`:42-49`), while discovery starts that observer at `WhereUI/Sources/Launch/WhereLaunch.swift:332-333` — one line before `prepareStore()` creates the intended store at `:334` (lines moved when PR #301 prepended the demo step; the observer-before-store ordering is unchanged). An unrelated CloudKit-backed store in the process could therefore release onboarding against an incomplete device list. Bind readiness to the container/store created for this launch (or return its initial-import completion directly from store preparation), ignore unrelated notifications, and cover that filtering with tests. (pr#160 review; citations refreshed 2026-09-06) - refactor(WhereUI) [quick-win]: Remove `StoredContext.CodingKeys`; it lists every property under the identical synthesized key and the installation-context sidecar has no shipped compatibility shape to preserve (`WhereUI/Sources/Launch/InstallationRecordingContextStore.swift:148-158`). Let the compiler synthesize the keys and retain the existing persistence round-trip coverage as the wire-shape guard. (pr#160 review) - feat(Where) [needs-design]: Add an optional onboarding step that backfills the current year from the GPS metadata of photos in the user's library. The onboarding phases currently move from region selection/customization directly to location permission (`OnboardingFlowModel.Phase`, `WhereUI/Sources/Onboarding/OnboardingFlowModel.swift:11-17` — the previously cited `OnboardingView.swift:30` no longer holds the enum), while `DayJournal.ingest(_:)` is the existing bulk sample path (`WhereCore/Sources/Journal/DayJournal.swift:99`). Design a PhotoKit-backed importer that requests access only after an explicit opt-in, reads location and capture time locally without uploading photo contents, previews what will be added, records photo-derived provenance rather than treating it as live GPS, deduplicates repeat imports, and makes skipping the screen frictionless. (human 2026-08-03) @@ -105,6 +104,8 @@ re-recording: # Completed issues +- fix(WhereUI) [quick-win]: Refresh the live-region welcome when the scene becomes active. Closed 2026-09-13: `MainTabs` now owns the welcome state and keys a bounded, confidence-gated lookup to foreground activity regardless of the selected tab. The source rejects stale one-shot callbacks, the resolver enforces the 1 km and boundary confidence gates, and app-shell tests and snapshots cover cancellation, repeated resolution, actionable status, and presentation over multiple tabs. (audit 2026-09-07, PR #309) + - docs(WhereCore) [quick-win]: Refresh stale doc claims. Closed 2026-09-07: the module AGENTS.md names the existing reconciliation exceptions; the README states the missing daily-summary fan-out and the failed-badge zero fallback; and the ingestor comment credits typed WhereLog events. PR #172 had already corrected the RegionViewer data-source description and RegionKit decoding-coverage claim; the earlier RootView and share-extension doc corrections remain shipped. The underlying summary, picker, badge, and GeoJSON-test items stay open. (audit 2026-07-26) - design(WhereCore): Model logged-in versus logged-out service ownership. Closed by PR #150's scope design: `WhereModel.ScopeState` (`WhereUI/Sources/Model/WhereModel.swift:50-58`) carries logged-out bootstrap, real scope, or demo scope. `WhereSession` exists only behind a resolved scope and holds non-optional services (`Model/WhereSession.swift:22-30`). The implementation makes the whole world optional rather than individual sub-services. (human; archived 2026-09-07) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 39a09f471..a4fd262cb 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -122,7 +122,10 @@ internal shape. report and primary-region locations. - **`LocationSource` abstracts GPS.** `CoreLocationSource` runs in production. `ScriptedLocationSource` runs in tests/previews. `requestCurrentLocation()` - returns `nil`, never throws. It backs + returns a typed, nonthrowing outcome and coalesces concurrent waiters without + coupling their cancellation. Reject negative accuracy everywhere. Apply the + 1 km, 60-second, and boundary-confidence gates only in + `CurrentRegionResolver`; retain other valid passive samples. It backs `LocationIngestor.captureTodayIfNeeded(now:)`. - **`DeviceRecordingController` owns this installation's local recording choice and physical GPS state.** Serialize mutations across awaits. Fail closed when diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 89dcd0f76..2036c300e 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -73,8 +73,12 @@ 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. +- **`CurrentRegionResolver`** — returns a typed live-region resolution only + while automatic recording is authorized. A successful decision requires a + fix no more than 60 seconds old, with valid horizontal accuracy at or below + 1 km, inside a tracked region, and farther from its boundary than the fix's + uncertainty radius. Its measured outcome logs contain only reason codes and + coarse age/accuracy buckets. - **`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 @@ -118,8 +122,11 @@ one it belongs to rather than to a god-object: - **`LocationSource`** — the GPS abstraction: `CoreLocationSource` (Visits + significant-change) in production, `ScriptedLocationSource` in tests/previews. - Passive `sampleStream` plus a best-effort one-shot `requestCurrentLocation()` - (returns `nil`, never throws, when no fix is available). + Passive `sampleStream` plus a bounded one-shot `requestCurrentLocation()` + whose nonthrowing result distinguishes permission, precision, timeout, + provider, and cancellation outcomes. Concurrent one-shot callers coalesce; + cancellation removes only that caller. Cached callbacks must pass the + one-minute freshness gate before satisfying them. - **`LocationIngestor`** — monitoring, the persist-with-retry queue, and authorization. After each committed sample it reconciles the badge/reminders and republishes the widget snapshot. Every automatic sample is stamped with diff --git a/Where/WhereCore/Sources/Forecasting/PlannedStayLocationVerifier.swift b/Where/WhereCore/Sources/Forecasting/PlannedStayLocationVerifier.swift index cabf5a2fb..b8bae40f8 100644 --- a/Where/WhereCore/Sources/Forecasting/PlannedStayLocationVerifier.swift +++ b/Where/WhereCore/Sources/Forecasting/PlannedStayLocationVerifier.swift @@ -24,7 +24,9 @@ public struct PlannedStayLocationVerifier: Sendable { for region: Region, driftThreshold: DriftThreshold, ) async -> Status { - guard let sample = await ingestor.currentLocation() else { return .unavailable } + guard case let .success(sample) = await ingestor.currentLocation() else { + return .unavailable + } if attributor.region(at: sample.coordinate) == region { return .accepted } guard let distance = attributor.distanceToBoundary( of: region, diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index b22103bb9..4a94743d6 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -23,10 +23,9 @@ import RegionKit /// that has a run loop (CoreLocation requires this). The /// `CLLocationManagerDelegate` methods are marked `nonisolated` because the /// `@objc` protocol contract doesn't permit `@MainActor` requirements; that -/// is safe here because the delegate code paths only `yield` to -/// `AsyncStream.Continuation` (thread-safe by construction) and never touch -/// `@MainActor` state. CoreLocation still delivers callbacks on the main -/// run loop, so no actual cross-thread hop occurs at runtime. +/// is safe here because they yield through thread-safe stream continuations +/// and explicitly hop to `MainActor` before touching one-shot request state. +/// Core Location still delivers callbacks on the main run loop in practice. @MainActor public final class CoreLocationSource: NSObject, LocationSource { public nonisolated let sampleStream: AsyncStream @@ -55,11 +54,24 @@ public final class CoreLocationSource: NSObject, LocationSource { /// coalesce onto the next delivered fix (or the shared timeout / failure); /// only the first triggers `requestLocation()`. Every waiter is resumed /// together, so a second caller can't strand the first. - private var pendingLocationContinuations: [CheckedContinuation] = [] + private var pendingLocationContinuations: [ + UUID: CheckedContinuation + ] = [:] + private var currentLocationRequestStartedAt: Date? + private var currentLocationTimeoutTask: Task? - /// How long to wait for a one-shot fix before giving up and recording no - /// captured location. Kept short so a manual entry's Save isn't held up. + #if DEBUG + private var testingAuthorizationStatus: LocationAuthorizationStatus? + private var testingHasPreciseLocation: Bool? + private var testingRequestLocation: (@MainActor @Sendable () -> Void)? + private var testingStopLocation: (@MainActor @Sendable () -> Void)? + private var testingCurrentLocationTimeout: Duration? + #endif + + /// How long to wait for a one-shot fix before reporting it unavailable. + /// Kept short so foreground callers are not held indefinitely. private static let currentLocationTimeout: Duration = .seconds(10) + private static let maximumCurrentLocationAge: TimeInterval = 60 override public init() { // The "create stream, capture its continuation" two-step is @@ -75,6 +87,7 @@ public final class CoreLocationSource: NSObject, LocationSource { manager = CLLocationManager() super.init() manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyKilometer } public func start() async { @@ -87,42 +100,160 @@ public final class CoreLocationSource: NSObject, LocationSource { manager.stopMonitoringVisits() } - public func requestCurrentLocation() async -> LocationSample? { - // Best-effort: without a granted status `requestLocation()` just fails, - // so short-circuit to "no fix" rather than starting a doomed request. - switch manager.authorizationStatus { - case .authorizedAlways, .authorizedWhenInUse: + public func requestCurrentLocation() async -> CurrentLocationResult { + let authorization = oneShotAuthorizationStatus + switch authorization { + case .always, .whenInUse: break case .denied, .restricted, .notDetermined: - return nil - @unknown default: - return nil + return .unavailable(.authorizationUnavailable(authorization)) + } + guard hasPreciseLocation else { + return .unavailable(.preciseLocationDisabled) } + guard !Task.isCancelled else { return .unavailable(.cancellation) } - return await withCheckedContinuation { continuation in - pendingLocationContinuations.append(continuation) - guard pendingLocationContinuations.count == 1 else { return } - manager.requestLocation() - // Bound the wait so a Save never hangs on a slow/absent fix. + let requestID = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + registerLocationWaiter(continuation, id: requestID) + } + } onCancel: { Task { @MainActor [weak self] in - try? await Task.sleep(for: Self.currentLocationTimeout) - self?.resolvePendingLocation(nil) + self?.cancelLocationWaiter(id: requestID) } } } + private func registerLocationWaiter( + _ continuation: CheckedContinuation, + id: UUID, + ) { + guard !Task.isCancelled else { + continuation.resume(returning: .unavailable(.cancellation)) + return + } + pendingLocationContinuations[id] = continuation + guard pendingLocationContinuations.count == 1 else { return } + currentLocationRequestStartedAt = Date() + requestUnderlyingLocation() + let timeout = currentLocationTimeout + currentLocationTimeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return + } + self?.resolvePendingLocation(.unavailable(.timeout)) + self?.stopUnderlyingLocation() + } + } + + private func cancelLocationWaiter(id: UUID) { + guard let waiter = pendingLocationContinuations.removeValue(forKey: id) else { return } + waiter.resume(returning: .unavailable(.cancellation)) + guard pendingLocationContinuations.isEmpty else { return } + currentLocationRequestStartedAt = nil + currentLocationTimeoutTask?.cancel() + currentLocationTimeoutTask = nil + stopUnderlyingLocation() + } + /// Resume (and clear) every coalesced one-shot location waiter with the same /// result. Cleared before resuming so a fix delivered after the timeout (or /// vice-versa) is a no-op rather than a double-resume. - private func resolvePendingLocation(_ sample: LocationSample?) { + private func resolvePendingLocation(_ result: CurrentLocationResult) { guard !pendingLocationContinuations.isEmpty else { return } - let waiters = pendingLocationContinuations + let waiters = pendingLocationContinuations.values pendingLocationContinuations.removeAll() + currentLocationRequestStartedAt = nil + currentLocationTimeoutTask?.cancel() + currentLocationTimeoutTask = nil for waiter in waiters { - waiter.resume(returning: sample) + waiter.resume(returning: result) } } + private func resolvePendingLocationIfFresh(_ samples: [LocationSample]) { + guard currentLocationRequestStartedAt != nil else { return } + let now = Date() + guard let freshest = samples + .filter({ abs(now.timeIntervalSince($0.timestamp)) <= Self.maximumCurrentLocationAge }) + .max(by: { $0.timestamp < $1.timestamp }) + else { return } + resolvePendingLocation(.success(freshest)) + } + + private var oneShotAuthorizationStatus: LocationAuthorizationStatus { + #if DEBUG + if let testingAuthorizationStatus { return testingAuthorizationStatus } + #endif + return Self.map(manager.authorizationStatus) + } + + private var hasPreciseLocation: Bool { + #if DEBUG + if let testingHasPreciseLocation { return testingHasPreciseLocation } + #endif + return manager.accuracyAuthorization == .fullAccuracy + } + + private var currentLocationTimeout: Duration { + #if DEBUG + if let testingCurrentLocationTimeout { return testingCurrentLocationTimeout } + #endif + return Self.currentLocationTimeout + } + + private func requestUnderlyingLocation() { + #if DEBUG + if let testingRequestLocation { + testingRequestLocation() + return + } + #endif + manager.requestLocation() + } + + private func stopUnderlyingLocation() { + #if DEBUG + if let testingStopLocation { + testingStopLocation() + return + } + #endif + manager.stopUpdatingLocation() + } + + #if DEBUG + /// Replaces the system-facing one-shot controls for deterministic tests. + @_spi(Testing) public func configureCurrentLocationForTesting( + authorization: LocationAuthorizationStatus, + hasPreciseLocation: Bool, + timeout: Duration, + request: @escaping @MainActor @Sendable () -> Void, + stop: @escaping @MainActor @Sendable () -> Void, + ) { + testingAuthorizationStatus = authorization + testingHasPreciseLocation = hasPreciseLocation + testingCurrentLocationTimeout = timeout + testingRequestLocation = request + testingStopLocation = stop + } + + /// Delivers a test batch through the same freshness gate as Core Location. + @_spi(Testing) public func deliverCurrentLocationsForTesting( + _ samples: [LocationSample], + ) { + resolvePendingLocationIfFresh(samples.filter { $0.horizontalAccuracy >= 0 }) + } + + /// Delivers a provider failure to every current one-shot waiter. + @_spi(Testing) public func failCurrentLocationForTesting() { + resolvePendingLocation(.unavailable(.providerFailure)) + } + #endif + public func currentAuthorization() async -> LocationAuthorizationStatus { Self.map(manager.authorizationStatus) } @@ -226,8 +357,9 @@ extension CoreLocationSource: CLLocationManagerDelegate { _: CLLocationManager, didUpdateLocations locations: [CLLocation], ) { - var latest: LocationSample? + var validSamples: [LocationSample] = [] for location in locations { + guard location.horizontalAccuracy >= 0 else { continue } let sample = LocationSample( timestamp: location.timestamp, coordinate: Coordinate( @@ -238,13 +370,13 @@ extension CoreLocationSource: CLLocationManagerDelegate { source: .gpsSignificantChange, ) sampleContinuation.yield(sample) - latest = sample + validSamples.append(sample) } // A one-shot `requestCurrentLocation()` is delivered here too; satisfy // any pending waiter with the freshest fix in this batch. - if let latest { + if !validSamples.isEmpty { Task { @MainActor [weak self] in - self?.resolvePendingLocation(latest) + self?.resolvePendingLocationIfFresh(validSamples) } } } @@ -257,7 +389,7 @@ extension CoreLocationSource: CLLocationManagerDelegate { // with "no fix" (best-effort audit capture) rather than leaving it to // wait out the full timeout. Task { @MainActor [weak self] in - self?.resolvePendingLocation(nil) + self?.resolvePendingLocation(.unavailable(.providerFailure)) } } @@ -265,6 +397,7 @@ extension CoreLocationSource: CLLocationManagerDelegate { _: CLLocationManager, didVisit visit: CLVisit, ) { + guard visit.horizontalAccuracy >= 0 else { return } // Core Location may deliver visits late or with only one of the two // timestamps populated. Prefer arrival; fall back to departure before // resorting to "now", since "now" would attribute the visit to the diff --git a/Where/WhereCore/Sources/Location/CurrentLocationResult.swift b/Where/WhereCore/Sources/Location/CurrentLocationResult.swift new file mode 100644 index 000000000..311e06b24 --- /dev/null +++ b/Where/WhereCore/Sources/Location/CurrentLocationResult.swift @@ -0,0 +1,22 @@ +import Foundation + +/// The explicit outcome of a bounded one-shot foreground location request. +public enum CurrentLocationResult: Sendable, Equatable { + /// Why Core Location could not provide a usable one-shot result. + public enum UnavailableReason: Sendable, Equatable { + case authorizationUnavailable(LocationAuthorizationStatus) + case preciseLocationDisabled + case timeout + case providerFailure + case cancellation + } + + case success(LocationSample) + case unavailable(UnavailableReason) + + /// The captured sample, when acquisition succeeded. + public var sample: LocationSample? { + guard case let .success(sample) = self else { return nil } + return sample + } +} diff --git a/Where/WhereCore/Sources/Location/CurrentRegionResolution.swift b/Where/WhereCore/Sources/Location/CurrentRegionResolution.swift new file mode 100644 index 000000000..b209ee0cc --- /dev/null +++ b/Where/WhereCore/Sources/Location/CurrentRegionResolution.swift @@ -0,0 +1,18 @@ +import RegionKit + +/// The confidence-gated result of resolving the device's current tracked region. +public enum CurrentRegionResolution: Sendable, Equatable { + /// Why a live fix did not produce a confident tracked-region decision. + public enum UnavailableReason: Sendable, Equatable { + case recordingInactive + case location(CurrentLocationResult.UnavailableReason) + case invalidFix + case staleFix + case excessiveUncertainty + case boundaryUncertainty + case outsideTrackedRegions + } + + case resolved(Region) + case unavailable(UnavailableReason) +} diff --git a/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift b/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift index 1be5aaf48..69832bab4 100644 --- a/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift +++ b/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift @@ -1,7 +1,12 @@ +import Foundation import RegionKit /// Resolves the device's current tracked region while automatic recording is authorized. public struct CurrentRegionResolver: Sendable { + private static let maximumFixAge: TimeInterval = 60 + private static let maximumHorizontalAccuracy = 1000.0 + private static let logger = WhereLog.location(CurrentRegionResolverLog.self) + private let ingestor: LocationIngestor private let attributor: any RegionAttributing @@ -10,12 +15,116 @@ public struct CurrentRegionResolver: Sendable { 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 } + /// Returns a tracked region only when a fresh live fix is confidently + /// attributable inside its boundary. + public func resolve(now: Date) async -> CurrentRegionResolution { + await Self.logger.measure(.resolve, budget: .seconds(10)) { + await resolveMeasured(now: now) + } + } + + private func resolveMeasured(now: Date) async -> CurrentRegionResolution { + guard await ingestor.isRecordingAuthorized else { + return finish(.unavailable(.recordingInactive), sample: nil, now: now) + } + let locationResult = await ingestor.currentLocation() + guard !Task.isCancelled else { + return finish(.unavailable(.location(.cancellation)), sample: nil, now: now) + } + let sample: LocationSample + switch locationResult { + case let .success(locationSample): + sample = locationSample + case let .unavailable(reason): + return finish(.unavailable(.location(reason)), sample: nil, now: now) + } + guard await ingestor.isRecordingAuthorized else { + return finish(.unavailable(.recordingInactive), sample: sample, now: now) + } + guard sample.horizontalAccuracy >= 0 else { + return finish(.unavailable(.invalidFix), sample: sample, now: now) + } + guard abs(now.timeIntervalSince(sample.timestamp)) <= Self.maximumFixAge else { + return finish(.unavailable(.staleFix), sample: sample, now: now) + } + guard sample.horizontalAccuracy <= Self.maximumHorizontalAccuracy else { + return finish(.unavailable(.excessiveUncertainty), sample: sample, now: now) + } + let region = attributor.region(at: sample.coordinate) - return region == .other ? nil : region + guard region != .other else { + return finish(.unavailable(.outsideTrackedRegions), sample: sample, now: now) + } + guard let boundaryDistance = attributor.distanceToBoundary( + of: region, + from: sample.coordinate, + ) else { + return finish(.unavailable(.outsideTrackedRegions), sample: sample, now: now) + } + guard sample.horizontalAccuracy < boundaryDistance else { + return finish(.unavailable(.boundaryUncertainty), sample: sample, now: now) + } + return finish(.resolved(region), sample: sample, now: now) + } + + private func finish( + _ resolution: CurrentRegionResolution, + sample: LocationSample?, + now: Date, + ) -> CurrentRegionResolution { + Self.logger { + .finished( + reason: reasonCode(for: resolution), + ageBucket: ageBucket(for: sample, now: now), + accuracyBucket: accuracyBucket(for: sample), + ) + } + return resolution + } + + private func reasonCode( + for resolution: CurrentRegionResolution, + ) -> CurrentRegionResolverLog.Reason { + switch resolution { + case .resolved: .resolved + case let .unavailable(reason): + switch reason { + case .recordingInactive: .recordingInactive + case .invalidFix: .invalidFix + case .staleFix: .staleFix + case .excessiveUncertainty: .excessiveUncertainty + case .boundaryUncertainty: .boundaryUncertainty + case .outsideTrackedRegions: .outsideTrackedRegions + case let .location(reason): + switch reason { + case .authorizationUnavailable: .authorizationUnavailable + case .preciseLocationDisabled: .preciseLocationDisabled + case .timeout: .timeout + case .providerFailure: .providerFailure + case .cancellation: .cancellation + } + } + } + } + + private func ageBucket( + for sample: LocationSample?, + now: Date, + ) -> CurrentRegionResolverLog.AgeBucket { + guard let sample else { return .unavailable } + let age = abs(now.timeIntervalSince(sample.timestamp)) + if age <= 10 { return .fresh } + if age <= 60 { return .recent } + return .stale + } + + private func accuracyBucket( + for sample: LocationSample?, + ) -> CurrentRegionResolverLog.AccuracyBucket { + guard let sample else { return .unavailable } + if sample.horizontalAccuracy < 0 { return .invalid } + if sample.horizontalAccuracy <= 100 { return .precise } + if sample.horizontalAccuracy <= 1000 { return .kilometer } + return .excessive } } diff --git a/Where/WhereCore/Sources/Location/IdleLocationSource.swift b/Where/WhereCore/Sources/Location/IdleLocationSource.swift index e257c0b88..aad8c83f3 100644 --- a/Where/WhereCore/Sources/Location/IdleLocationSource.swift +++ b/Where/WhereCore/Sources/Location/IdleLocationSource.swift @@ -8,8 +8,8 @@ import Foundation /// (Siri / Spotlight / Shortcuts), which builds its stack via /// `WhereServices.forIntents()`. Unlike `CoreLocationSource`, it installs no /// `CLLocationManager`; unlike `ScriptedLocationSource`, it has no test seams — -/// it's simply inert. `requestCurrentLocation()` returns `nil` (the same honest -/// "no fix" a manual entry records), so an intent-made manual day carries an +/// it's simply inert. `requestCurrentLocation()` reports unavailable, so an +/// intent-made manual day carries an /// audit with no captured location rather than a faked one. public final class IdleLocationSource: LocationSource { public init() {} @@ -27,8 +27,8 @@ public final class IdleLocationSource: LocationSource { public func start() async {} public func stop() async {} - public func requestCurrentLocation() async -> LocationSample? { - nil + public func requestCurrentLocation() async -> CurrentLocationResult { + .unavailable(.authorizationUnavailable(.notDetermined)) } /// Reports `.notDetermined`: an inert source has never prompted, and intents diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index e4759b5d7..2e2e469a6 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -339,12 +339,9 @@ public actor LocationIngestor { try await locationSource.requestPermission() } - /// Best-effort one-shot GPS fix for "where is the device right now", used to - /// stamp a manual entry's audit trail. Returns `nil` when no fix is - /// available (permission not granted, timeout); the caller records the entry - /// either way. Routed through the ingestor so the UI never touches the - /// `LocationSource` directly. - public func currentLocation() async -> LocationSample? { + /// Bounded one-shot GPS fix for "where is the device right now". Routed + /// through the ingestor so presentation never touches `LocationSource`. + public func currentLocation() async -> CurrentLocationResult { await locationSource.requestCurrentLocation() } @@ -396,7 +393,7 @@ public actor LocationIngestor { let fix = await Self.logger.measure(.acquireFix, budget: .seconds(10)) { await locationSource.requestCurrentLocation() } - guard let sample = fix else { return } + guard case let .success(sample) = fix else { return } // The ~10s fix may have straddled a `pause()`; re-check the gate before // persisting, mirroring `ingest(_:)`. The guard and the `capturePersistTask` // assignment to the capture task is synchronous (no `await` between), so a concurrent @@ -445,7 +442,7 @@ public actor LocationIngestor { private func accepts(_ sample: LocationSample) -> Bool { guard case let .open(_, effectiveAt) = recordingAuthority else { return false } - return sample.timestamp >= effectiveAt + return sample.horizontalAccuracy >= 0 && sample.timestamp >= effectiveAt } /// Persist one GPS-sourced sample, falling back to the retry queue on diff --git a/Where/WhereCore/Sources/Location/LocationSource.swift b/Where/WhereCore/Sources/Location/LocationSource.swift index 09114060c..4749cf9f1 100644 --- a/Where/WhereCore/Sources/Location/LocationSource.swift +++ b/Where/WhereCore/Sources/Location/LocationSource.swift @@ -40,17 +40,15 @@ public protocol LocationSource: AnyObject, Sendable { func start() async func stop() async - /// Best-effort one-shot GPS fix for "where is the device *right now*". + /// Bounded one-shot GPS fix for "where is the device *right now*". /// /// Unlike the passive `sampleStream` (Visits + significant-change, which can - /// be minutes stale), this actively asks for a fresh fix. Two callers use - /// it: stamping a manual entry's audit trail with where it was made, and + /// be minutes stale), this actively asks for a fresh fix. It supports + /// manual-entry audit capture, app-wide region resolution, and /// `LocationIngestor.captureTodayIfNeeded(now:)`, which persists a fix for - /// today when the app opens on a day that has no GPS sample yet. Returns - /// `nil` rather than throwing when a fix can't be obtained (permission not - /// granted, timeout, or a location error), so an absent fix is recorded - /// honestly instead of blocking the caller. - func requestCurrentLocation() async -> LocationSample? + /// today when the app opens on a day that has no GPS sample yet. The + /// nonthrowing result preserves why a fix was unavailable. + func requestCurrentLocation() async -> CurrentLocationResult /// The current authorization status, read on demand. func currentAuthorization() async -> LocationAuthorizationStatus @@ -84,9 +82,9 @@ public final class ScriptedLocationSource: LocationSource, @unchecked Sendable { private let lock = NSLock() private var _status: LocationAuthorizationStatus - /// What the next `requestCurrentLocation()` returns. Defaults to `nil` (no - /// fix available) so tests opt in to a captured location explicitly. - private var _nextRequestedLocation: LocationSample? + /// What the next `requestCurrentLocation()` returns. Defaults to timeout so + /// tests opt in to a captured location explicitly. + private var _nextRequestedLocationResult: CurrentLocationResult = .unavailable(.timeout) /// - Parameters: /// - permissionResult: what the next call to `requestPermission()` @@ -109,14 +107,23 @@ public final class ScriptedLocationSource: LocationSource, @unchecked Sendable { public func start() async {} public func stop() async {} - public func requestCurrentLocation() async -> LocationSample? { - lock.withLock { _nextRequestedLocation } + public func requestCurrentLocation() async -> CurrentLocationResult { + guard !Task.isCancelled else { return .unavailable(.cancellation) } + return lock.withLock { _nextRequestedLocationResult } } /// Set the fix the next `requestCurrentLocation()` will return (or `nil` to - /// simulate no fix). Mirrors how `emit(_:)` scripts the passive stream. + /// simulate a timeout). Mirrors how `emit(_:)` scripts the passive stream. public func setNextRequestedLocation(_ sample: LocationSample?) { - lock.withLock { _nextRequestedLocation = sample } + lock.withLock { + _nextRequestedLocationResult = sample.map(CurrentLocationResult.success) + ?? .unavailable(.timeout) + } + } + + /// Set the complete typed outcome for the next one-shot request. + public func setNextRequestedLocationResult(_ result: CurrentLocationResult) { + lock.withLock { _nextRequestedLocationResult = result } } public func currentAuthorization() async -> LocationAuthorizationStatus { diff --git a/Where/WhereCore/Sources/Logging/CurrentRegionResolverLog.swift b/Where/WhereCore/Sources/Logging/CurrentRegionResolverLog.swift new file mode 100644 index 000000000..65af3f6de --- /dev/null +++ b/Where/WhereCore/Sources/Logging/CurrentRegionResolverLog.swift @@ -0,0 +1,81 @@ +import PeriscopeCore + +/// PII-free outcomes for foreground region resolution. +enum CurrentRegionResolverLog: LogEvent { + enum Kind: String, CaseIterable, Codable { + case finished + } + + enum Reason: String, CaseIterable, Codable { + case resolved + case recordingInactive = "recording-inactive" + case invalidFix = "invalid-fix" + case staleFix = "stale-fix" + case excessiveUncertainty = "excessive-uncertainty" + case boundaryUncertainty = "boundary-uncertainty" + case outsideTrackedRegions = "outside-tracked-regions" + case authorizationUnavailable = "authorization-unavailable" + case preciseLocationDisabled = "precise-location-disabled" + case timeout + case providerFailure = "provider-failure" + case cancellation + } + + enum AgeBucket: String, CaseIterable, Codable { + case unavailable + case fresh = "0-10s" + case recent = "11-60s" + case stale = "over-60s" + } + + enum AccuracyBucket: String, CaseIterable, Codable { + case unavailable + case invalid + case precise = "0-100m" + case kilometer = "101-1000m" + case excessive = "over-1000m" + } + + enum SpanName: Hashable { + case resolve + } + + case finished(reason: Reason, ageBucket: AgeBucket, accuracyBucket: AccuracyBucket) + + static let eventName = "CurrentRegionResolver" + + var level: LogLevel { + switch self { + case let .finished(reason, _, _): + reason == .resolved ? .info : .warning + } + } + + var message: String { + switch self { + case let .finished(reason, ageBucket, accuracyBucket): + "Current region resolution finished: \(reason.rawValue), age \(ageBucket.rawValue), accuracy \(accuracyBucket.rawValue)" + } + } + + var remoteFields: [RemoteLogField] { + switch self { + case let .finished(reason, ageBucket, accuracyBucket): + [ + .eventKind(Kind.finished), + RemoteLogField( + key: RemoteLogFieldKey("reason"), + value: .category(RemoteLogCategory(reason)), + ), + RemoteLogField( + key: RemoteLogFieldKey("age_bucket"), + value: .category(RemoteLogCategory(ageBucket)), + ), + RemoteLogField( + key: RemoteLogFieldKey("accuracy_bucket"), + value: .category(RemoteLogCategory(accuracyBucket)), + ), + ] + } + } +} diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 9f1bf9c35..6deecf7fe 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -63,7 +63,7 @@ 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. + /// Confidence-gated live tracked-region lookup for presentation acknowledgements. public let currentRegion: CurrentRegionResolver /// Data-quality issue detection for the Resolve tab. public let resolution: DataIssueScanner diff --git a/Where/WhereCore/Tests/CoreLocationSourceTests.swift b/Where/WhereCore/Tests/CoreLocationSourceTests.swift new file mode 100644 index 000000000..353aca355 --- /dev/null +++ b/Where/WhereCore/Tests/CoreLocationSourceTests.swift @@ -0,0 +1,128 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) import WhereCore + +@MainActor +struct CoreLocationSourceTests { + @Test func reducedPrecisionReturnsAnExplicitFailureWithoutRequesting() async { + let (source, probe) = configuredSource(hasPreciseLocation: false) + + #expect( + await source.requestCurrentLocation() == .unavailable(.preciseLocationDisabled), + ) + #expect(probe.requestCount == 0) + } + + @Test func deniedAuthorizationReturnsAnExplicitFailureWithoutRequesting() async { + let (source, probe) = configuredSource(authorization: .denied) + + #expect( + await source.requestCurrentLocation() + == .unavailable(.authorizationUnavailable(.denied)), + ) + #expect(probe.requestCount == 0) + } + + @Test func staleCachedCallbackDoesNotSatisfyPendingRequest() async { + let (source, probe) = configuredSource() + let request = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 1 } + let stale = sample(timestamp: Date().addingTimeInterval(-61)) + let fresh = sample() + + source.deliverCurrentLocationsForTesting([stale]) + source.deliverCurrentLocationsForTesting([fresh]) + + #expect(await request.value == .success(fresh)) + } + + @Test func negativeAccuracyCallbackDoesNotSatisfyPendingRequest() async { + let (source, probe) = configuredSource() + let request = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 1 } + let invalid = sample(accuracy: -1) + let valid = sample() + + source.deliverCurrentLocationsForTesting([invalid]) + source.deliverCurrentLocationsForTesting([valid]) + + #expect(await request.value == .success(valid)) + } + + @Test func providerFailureHasAnExplicitOutcome() async { + let (source, probe) = configuredSource() + let request = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 1 } + + source.failCurrentLocationForTesting() + + #expect(await request.value == .unavailable(.providerFailure)) + } + + @Test func boundedRequestTimesOutAndStopsTheUnderlyingRequest() async { + let (source, probe) = configuredSource(timeout: .milliseconds(1)) + + #expect(await source.requestCurrentLocation() == .unavailable(.timeout)) + #expect(probe.requestCount == 1) + #expect(probe.stopCount == 1) + } + + @Test func concurrentWaitersCoalesceAndCancellationRemovesOnlyOne() async { + let (source, probe) = configuredSource() + let first = Task { await source.requestCurrentLocation() } + let second = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 1 } + + first.cancel() + #expect(await first.value == .unavailable(.cancellation)) + #expect(probe.stopCount == 0) + + let fix = sample() + source.deliverCurrentLocationsForTesting([fix]) + + #expect(await second.value == .success(fix)) + #expect(probe.requestCount == 1) + } + + private func configuredSource( + authorization: LocationAuthorizationStatus = .always, + hasPreciseLocation: Bool = true, + timeout: Duration = .seconds(10), + ) -> (CoreLocationSource, LocationRequestProbe) { + let source = CoreLocationSource() + let probe = LocationRequestProbe() + source.configureCurrentLocationForTesting( + authorization: authorization, + hasPreciseLocation: hasPreciseLocation, + timeout: timeout, + request: { probe.requestCount += 1 }, + stop: { probe.stopCount += 1 }, + ) + return (source, probe) + } + + private func sample( + timestamp: Date = Date(), + accuracy: Double = 5, + ) -> LocationSample { + LocationSample( + timestamp: timestamp, + coordinate: Coordinate(latitude: 40.7128, longitude: -74.0060), + horizontalAccuracy: accuracy, + source: .gpsSignificantChange, + ) + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + while condition() == false { + await Task.yield() + } + } +} + +@MainActor +private final class LocationRequestProbe { + var requestCount = 0 + var stopCount = 0 +} diff --git a/Where/WhereCore/Tests/CurrentRegionResolverTests.swift b/Where/WhereCore/Tests/CurrentRegionResolverTests.swift index 414071315..e48a97729 100644 --- a/Where/WhereCore/Tests/CurrentRegionResolverTests.swift +++ b/Where/WhereCore/Tests/CurrentRegionResolverTests.swift @@ -4,90 +4,205 @@ 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() + private let now = Date(timeIntervalSinceReferenceDate: 10000) + + @Test func freshConfidentFixResolvesTrackedRegion() async throws { + let services = try await authorizedServices(sample: sample()) - #expect(await services.currentRegion.resolve() == .california) + #expect(await services.currentRegion.resolve(now: now) == .resolved(.california)) } - @Test func inactiveRecordingDoesNotRequestAWelcomeRegion() async throws { - let (services, source) = try makeServices() - source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194)) + @Test func exactlyOneKilometerIsAccepted() async throws { + let services = try await authorizedServices( + sample: sample(accuracy: 1000), + boundaryDistance: 1001, + ) - #expect(await services.currentRegion.resolve() == nil) + #expect(await services.currentRegion.resolve(now: now) == .resolved(.california)) } - @Test func missingFixDoesNotResolveAWelcomeRegion() async throws { - let (services, _) = try makeServices() - try await services.ingestor.authorizeRecording() + @Test func accuracyAboveOneKilometerIsRejected() async throws { + let services = try await authorizedServices(sample: sample(accuracy: 1001)) + + #expect( + await services.currentRegion.resolve(now: now) + == .unavailable(.excessiveUncertainty), + ) + } + + @Test func negativeAccuracyIsInvalid() async throws { + let services = try await authorizedServices(sample: sample(accuracy: -1)) - #expect(await services.currentRegion.resolve() == nil) + #expect(await services.currentRegion.resolve(now: now) == .unavailable(.invalidFix)) } - @Test func locationOutsideTrackedRegionsDoesNotResolveAWelcomeRegion() async throws { - let (services, source) = try makeServices( - attributor: RegionAttributor(for: [.california]), + @Test func fixOlderThanSixtySecondsIsStale() async throws { + let services = try await authorizedServices( + sample: sample(timestamp: now.addingTimeInterval(-61)), + ) + + #expect(await services.currentRegion.resolve(now: now) == .unavailable(.staleFix)) + } + + @Test func fixExactlySixtySecondsOldIsAccepted() async throws { + let services = try await authorizedServices( + sample: sample(timestamp: now.addingTimeInterval(-60)), ) - source.setNextRequestedLocation(sample(latitude: 40.7128, longitude: -74.0060)) - try await services.ingestor.authorizeRecording() - #expect(await services.currentRegion.resolve() == nil) + #expect(await services.currentRegion.resolve(now: now) == .resolved(.california)) } - @Test func authorizationRevokedDuringFixRequestDoesNotResolveAWelcomeRegion() async throws { + @Test func uncertaintyThatReachesBoundaryIsRejected() async throws { + let services = try await authorizedServices( + sample: sample(accuracy: 500), + boundaryDistance: 500, + ) + + #expect( + await services.currentRegion.resolve(now: now) + == .unavailable(.boundaryUncertainty), + ) + } + + @Test func locationOutsideTrackedRegionsIsRejected() async throws { + let services = try await authorizedServices(sample: sample(), region: .other) + + #expect( + await services.currentRegion.resolve(now: now) + == .unavailable(.outsideTrackedRegions), + ) + } + + @Test( + arguments: [ + CurrentLocationResult.UnavailableReason.preciseLocationDisabled, + .providerFailure, + .timeout, + .cancellation, + .authorizationUnavailable(.denied), + ], + ) + func locationFailureIsPreserved( + reason: CurrentLocationResult.UnavailableReason, + ) async throws { + let services = try await authorizedServices(result: .unavailable(reason)) + + #expect( + await services.currentRegion.resolve(now: now) + == .unavailable(.location(reason)), + ) + } + + @Test func inactiveRecordingDoesNotRequestAWelcomeRegion() async throws { + let source = ScriptedLocationSource() + source.setNextRequestedLocation(sample()) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + attributor: FixedRegionAttributor(), + ) + + #expect( + await services.currentRegion.resolve(now: now) + == .unavailable(.recordingInactive), + ) + } + + @Test func authorizationRevokedDuringFixRequestDoesNotResolveARegion() async throws { let source = GatedWelcomeLocationSource() let services = try WhereServices( store: SwiftDataStore.inMemory(), locationSource: source, + attributor: FixedRegionAttributor(), ) try await services.ingestor.authorizeRecording() - let resolution = Task { await services.currentRegion.resolve() } + let resolution = Task { await services.currentRegion.resolve(now: now) } await source.waitUntilRequested() await services.ingestor.revokeRecordingAuthorization() - await source.resolve(with: sample(latitude: 37.7749, longitude: -122.4194)) + await source.resolve(with: .success(sample())) + + #expect(await resolution.value == .unavailable(.recordingInactive)) + } - #expect(await resolution.value == nil) + private func authorizedServices( + sample: LocationSample, + boundaryDistance: Double = 10000, + region: Region = .california, + ) async throws -> WhereServices { + try await authorizedServices( + result: .success(sample), + boundaryDistance: boundaryDistance, + region: region, + ) } - private func makeServices( - attributor: any RegionAttributing = RegionAttributor.shared, - ) throws -> (WhereServices, ScriptedLocationSource) { + private func authorizedServices( + result: CurrentLocationResult, + boundaryDistance: Double = 10000, + region: Region = .california, + ) async throws -> WhereServices { let source = ScriptedLocationSource() - return try ( - WhereServices( - store: SwiftDataStore.inMemory(), - locationSource: source, - attributor: attributor, + source.setNextRequestedLocationResult(result) + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + attributor: FixedRegionAttributor( + region: region, + boundaryDistance: boundaryDistance, ), - source, ) + try await services.ingestor.authorizeRecording() + return services } - private func sample(latitude: Double, longitude: Double) -> LocationSample { + private func sample( + timestamp: Date? = nil, + accuracy: Double = 5, + ) -> LocationSample { LocationSample( - timestamp: Date(timeIntervalSinceReferenceDate: 0), - coordinate: Coordinate(latitude: latitude, longitude: longitude), - horizontalAccuracy: 5, + timestamp: timestamp ?? now, + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: accuracy, source: .gpsSignificantChange, ) } } +private struct FixedRegionAttributor: RegionAttributing { + let region: Region + let boundaryDistance: Double? + + init(region: Region = .california, boundaryDistance: Double? = 10000) { + self.region = region + self.boundaryDistance = boundaryDistance + } + + var loadedRegions: [Region] { + region == .other ? [] : [region] + } + + func region(at _: Coordinate) -> Region { + region + } + + func distanceToBoundary(of _: Region, from _: Coordinate) -> Double? { + boundaryDistance + } +} + private actor GatedWelcomeLocationSource: LocationSource { nonisolated let sampleStream = AsyncStream { $0.finish() } nonisolated let authorizationUpdates = AsyncStream { $0.finish() } - private var requestContinuation: CheckedContinuation? + private var requestContinuation: CheckedContinuation? private var requestWaiters: [CheckedContinuation] = [] private var didRequest = false func start() async {} func stop() async {} - func requestCurrentLocation() async -> LocationSample? { + func requestCurrentLocation() async -> CurrentLocationResult { didRequest = true for waiter in requestWaiters { waiter.resume() @@ -107,8 +222,8 @@ private actor GatedWelcomeLocationSource: LocationSource { await withCheckedContinuation { requestWaiters.append($0) } } - func resolve(with sample: LocationSample?) { - requestContinuation?.resume(returning: sample) + func resolve(with result: CurrentLocationResult) { + requestContinuation?.resume(returning: result) requestContinuation = nil } } diff --git a/Where/WhereCore/Tests/IdleLocationSourceTests.swift b/Where/WhereCore/Tests/IdleLocationSourceTests.swift index 3a6407cfe..f6040cc04 100644 --- a/Where/WhereCore/Tests/IdleLocationSourceTests.swift +++ b/Where/WhereCore/Tests/IdleLocationSourceTests.swift @@ -9,7 +9,10 @@ struct IdleLocationSourceTests { @Test func reportsNoAuthorizationAndNoFix() async { let source = IdleLocationSource() #expect(await source.currentAuthorization() == .notDetermined) - #expect(await source.requestCurrentLocation() == nil) + #expect( + await source.requestCurrentLocation() + == .unavailable(.authorizationUnavailable(.notDetermined)), + ) } @Test func requestPermissionIsANoOpAndDoesNotThrow() async throws { diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index 171b1002c..f52c31eb8 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -111,16 +111,16 @@ struct LocationIngestorTests { ) source.setNextRequestedLocation(fix) - #expect(await ingestor.currentLocation() == fix) + #expect(await ingestor.currentLocation() == .success(fix)) } - @Test func currentLocationIsNilWhenSourceHasNoFix() async throws { + @Test func currentLocationReportsTimeoutWhenSourceHasNoFix() async throws { let store = try SwiftDataStore.inMemory() let source = ScriptedLocationSource() let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - #expect(await ingestor.currentLocation() == nil) + #expect(await ingestor.currentLocation() == .unavailable(.timeout)) } @Test func captureTodayPersistsAndReportsFixWhenNoGPSSampleYet() async throws { @@ -145,6 +145,50 @@ struct LocationIngestorTests { #expect(stored.first?.recordingDeviceID == CurrentRecordingDevice.preview.id) } + @Test func passiveSampleWithNegativeAccuracyIsDropped() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + ) + let invalid = LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-03-15T08:05:00-07:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: -1, + source: .gpsSignificantChange, + ) + try await ingestor.start() + + source.emit(invalid) + try await waitUntil { await ingestor.testingHasConsumedSample(id: invalid.id) } + + #expect(try await store.allSamples().isEmpty) + } + + @Test func passiveSampleAboveOneKilometerIsRetained() async throws { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: .always) + let ingestor = Self.makeIngestor( + store: store, + source: source, + recorder: OutcomeRecorder(), + ) + let coarse = LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-03-15T08:05:00-07:00"), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 1500, + source: .gpsSignificantChange, + ) + try await ingestor.start() + + source.emit(coarse) + try await waitUntil { await ingestor.testingHasConsumedSample(id: coarse.id) } + + #expect(try await store.allSamples().map(\.id) == [coarse.id]) + } + @Test func captureTodaySkipsWhenGPSSampleAlreadyExistsToday() async throws { let store = try SwiftDataStore.inMemory() let source = ScriptedLocationSource(authorizationStatus: .whenInUse) @@ -194,7 +238,7 @@ struct LocationIngestorTests { let source = ScriptedLocationSource(authorizationStatus: .whenInUse) let recorder = OutcomeRecorder() let ingestor = Self.makeIngestor(store: store, source: source, recorder: recorder) - // No fix scripted → `requestCurrentLocation()` returns nil. + // No fix scripted → `requestCurrentLocation()` reports a timeout. try await ingestor.authorizeRecording() await ingestor @@ -284,7 +328,7 @@ struct LocationIngestorTests { try await ingestor.start() await ingestor.revokeRecordingAuthorization() - #expect(await ingestor.currentLocation() != nil) + #expect(await ingestor.currentLocation().sample != nil) try await waitUntil { guard source.didEchoFix else { return false } return await ingestor.testingHasConsumedSample(id: source.fixID) @@ -810,12 +854,12 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { func requestPermission() async throws {} - func requestCurrentLocation() async -> LocationSample? { + func requestCurrentLocation() async -> CurrentLocationResult { let shouldWait = lock.withLock { _requestCount += 1 return !isOpen } - guard shouldWait else { return fix } + guard shouldWait else { return .success(fix) } await withCheckedContinuation { continuation in let openedBeforeRegistration = lock.withLock { guard !isOpen else { return true } @@ -824,7 +868,7 @@ private final class GatedLocationSource: LocationSource, @unchecked Sendable { } if openedBeforeRegistration { continuation.resume() } } - return fix + return .success(fix) } /// Resume every parked fix request with the scripted fix. @@ -877,10 +921,10 @@ private final class EchoingLocationSource: LocationSource, @unchecked Sendable { func requestPermission() async throws {} - func requestCurrentLocation() async -> LocationSample? { + func requestCurrentLocation() async -> CurrentLocationResult { lock.withLock { _didEchoFix = true } continuation.yield(fix) - return fix + return .success(fix) } } diff --git a/Where/WhereCore/Tests/Logging/CurrentRegionResolverLogTests.swift b/Where/WhereCore/Tests/Logging/CurrentRegionResolverLogTests.swift new file mode 100644 index 000000000..26cc4ac48 --- /dev/null +++ b/Where/WhereCore/Tests/Logging/CurrentRegionResolverLogTests.swift @@ -0,0 +1,25 @@ +import PeriscopeCore +import Testing +@testable import WhereCore + +struct CurrentRegionResolverLogTests { + @Test func finishedEventExportsOnlyBoundedNonLocationFields() { + let event = CurrentRegionResolverLog.finished( + reason: .boundaryUncertainty, + ageBucket: .recent, + accuracyBucket: .kilometer, + ) + + #expect(event.remoteFields.map(\.key) == [ + RemoteLogFieldKey("kind"), + RemoteLogFieldKey("reason"), + RemoteLogFieldKey("age_bucket"), + RemoteLogFieldKey("accuracy_bucket"), + ]) + #expect(event.message.contains("boundary-uncertainty")) + #expect(event.message.contains("11-60s")) + #expect(event.message.contains("101-1000m")) + #expect(event.message.contains("latitude") == false) + #expect(event.message.contains("longitude") == false) + } +} diff --git a/Where/WhereCore/Tests/WhereServices+IntentsTests.swift b/Where/WhereCore/Tests/WhereServices+IntentsTests.swift index ba0d53e49..437470239 100644 --- a/Where/WhereCore/Tests/WhereServices+IntentsTests.swift +++ b/Where/WhereCore/Tests/WhereServices+IntentsTests.swift @@ -30,7 +30,10 @@ struct WhereServicesIntentsTests { // The idle source backs the ingestor, so a manual entry made from an // intent honestly records "no captured location" rather than a fix. - #expect(await services.ingestor.currentLocation() == nil) + #expect( + await services.ingestor.currentLocation() + == .unavailable(.authorizationUnavailable(.notDetermined)), + ) } } @@ -56,7 +59,10 @@ struct WhereServicesForIntentsSharingTests { let shared = WhereServices.forIntents(sharingStoreOf: base) - #expect(await shared.ingestor.currentLocation() == nil) + #expect( + await shared.ingestor.currentLocation() + == .unavailable(.authorizationUnavailable(.notDetermined)), + ) } @Test func writesThroughTheSharedStackAreVisibleToTheBase() async throws { diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index d9406213b..a6cc79584 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -163,6 +163,10 @@ worked examples. Location-card stack, never Card Designer persistence, exports, or app overrides. - Keep the Appearance welcome reset under `#if DEBUG`. Clear only the saved welcome region through `YearReportModel.resetLocationWelcome()`. - Keep welcome-card arrival, departure, and scrim timing in `locationWelcome.motion`. Apply spatial transitions only to the card layer. +- Keep `LocationWelcomeModel`, its overlay, and its bottom accessory owned by + `MainTabs`. Resolve once per active-scene entry regardless of tab. Only denied + or restricted access and disabled Precise Location stay visible as recovery + actions; transient and confidence failures return to idle. ## Testing diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 77a4d1265..01a0620a7 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -124,8 +124,9 @@ 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 preference-gated current-region welcome and - its persisted acknowledgement). The Location model holds saved values until the card surface + the user saw), and **`LocationWelcomeModel`** (the `MainTabs`-owned, + preference-gated current-region acquisition, recovery, welcome, and persisted + acknowledgement state machine). 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 @@ -135,13 +136,17 @@ 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, +- **`RegionWelcomeCard`** — an app-wide overlay over the selected tab that combines a region's emoji, icon, outline, Liquid Glass card treatment, and passport ink. The card stamps into place with a quick tilted approach and spring settle, then lifts away on dismissal. The scrim fades independently. Reduce Motion uses a short fade for both layers. Debug builds include **Reset Welcome Card** in Settings > Appearance beside the welcome-card toggle. - The reset clears the saved region so the next Locations visit can show the card again. - Welcome cards must be enabled, and the device must resolve a tracked region with recording active. + The reset clears the saved region so the next foreground activation can show the card again. + `MainTabs` requests one bounded fix on each active-scene entry. After one + second it shows a native tab-bar accessory; denied/restricted access and + disabled Precise Location remain actionable there, while transient or + low-confidence failures disappear. Welcome cards must be enabled, and the + device must confidently resolve a tracked region with recording active. - **`OnboardingView` / `OnboardingFlowModel`** — the rendered first-run flow and its view-scoped observable coordinator, registered for the launch's diff --git a/Where/WhereUI/SnapshotTests/MainTabsSnapshotTests.swift b/Where/WhereUI/SnapshotTests/MainTabsSnapshotTests.swift new file mode 100644 index 000000000..81957685f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/MainTabsSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct MainTabsSnapshotTests { + @Test func mainTabs() async { + await assertSnapshots(of: MainTabs.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png deleted file mode 100644 index c4c5edf45..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -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 deleted file mode 100644 index 732b1e7eb..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -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 deleted file mode 100644 index dacfdbd69..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -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 deleted file mode 100644 index 1cac1600e..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -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 deleted file mode 100644 index 5374758e9..000000000 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8ed3d5c73b6ac4950417e6f6d33dd789334a7efbb46fa995db31b2b7502bdc7c -size 1885876 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png new file mode 100644 index 000000000..cb7b7901e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac57110825bb8848480c83099eade66b701468acf196f370a2c3e2140aa0cc93 +size 678841 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png new file mode 100644 index 000000000..952c311c9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbfa930ccff34aa6db885710a580cef2db71657e30196218cf2c34438f93b628 +size 647755 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png new file mode 100644 index 000000000..c0ca6736e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63117e30a127f0fdd120e988a916c293a97ee785b20184e894f16f0741d4eb86 +size 747032 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png new file mode 100644 index 000000000..5d2a9ee82 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:595ce5a52be93a6f2d29c7fbc8229ab9120cd485e726ae4c7fd4163a64561457 +size 890468 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png new file mode 100644 index 000000000..edea56cb7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82f2b208a44ac131fcdf8b60c35d2684fb12b0ab8939494ccfcfd834df3ca258 +size 677804 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png new file mode 100644 index 000000000..e09cd8660 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f52110c6e3ac7f060a5dd19a76dbf9be7ac53a4566937a948d1a5bd291a745a +size 644942 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png new file mode 100644 index 000000000..b5e803ab7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d33831121a7abaff3b81964dd6acca722f83142e09bdf2fc777b5b5898bd1109 +size 749760 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png new file mode 100644 index 000000000..bd91ead8e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f8871a7be2ccc2698ad97a2f99d64803f4a54cf7c9b4f75b55c00bb1251ab10 +size 889700 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png new file mode 100644 index 000000000..0c3b3dd43 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39658ffd42277265027a0dfda24c6865f5dc1fdf404bd280cf7c62a2bc7983ec +size 1051134 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png new file mode 100644 index 000000000..23fa40fe9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4186726b4dd42a1de0bbbd16e45500b13e42d89bd46d6f1613fae243031c123d +size 926142 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_ax5.png new file mode 100644 index 000000000..0dbcacf50 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:851d7a31cedb1004dcf8a01fde396223c8c7f6558f854ce3a5deade17df61ae0 +size 2360955 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png new file mode 100644 index 000000000..7072cf958 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6651598772656be913cf175e7ba91256424e6044870d31afe8f59e7c823df0d7 +size 810318 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png new file mode 100644 index 000000000..72ff8da42 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f261052884173e18e0c0425b748fcbb499f9060a18d846af50db2dbbce1f897 +size 1443293 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png new file mode 100644 index 000000000..d1b21b47c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:652ee577102154f0c47cfb82cd1aa3f6ecc30e8f8c2ed1e9375626e078a50ff2 +size 1096131 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png new file mode 100644 index 000000000..d724f078a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5af839965eb7898b9e3043b68f7b6f1a3ee6b947cba1d1444926910cb26bd175 +size 2373009 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png new file mode 100644 index 000000000..62d823360 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4354011fa2bf5f4a47e23c3228b542efbe2543324350c8f0c1143efc8d189776 +size 1423680 diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 7eb072d06..fccc6443c 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -1,4 +1,6 @@ +import RegionKit import SFSafeSymbols +import SnapshotKit import SwiftUI import WhereCore @@ -22,11 +24,28 @@ struct MainTabs: View { case settings } + private struct WelcomeTaskID: Hashable { + let isActive: Bool + let isEnabled: Bool + } + + private struct PlannedStayEditorTarget: Identifiable { + let region: Region + + var id: Region { + region + } + } + @State private var report: YearReportModel @State private var recordingWarning: RecordingConfigurationWarningModel + @State private var welcome: LocationWelcomeModel + @State private var plannedStayEditorTarget: PlannedStayEditorTarget? @State private var selection: TabID = .locations @Environment(\.scenePhase) private var scenePhase + @Environment(\.stylesheet) private var stylesheet private let recordingWarningSource: RecordingConfigurationWarningModel.Source + private let allowsWelcomeLookup: Bool /// Build the scene's report model from the coordinator's service layer. /// `initialDetails` / `selectedYear` are the preview/test seam threaded from @@ -34,6 +53,7 @@ struct MainTabs: View { init(session: WhereSession, initialDetails: YearReportDetails?, selectedYear: Int) { let recordingWarningSource = RecordingConfigurationWarningModel.Source(session: session) self.recordingWarningSource = recordingWarningSource + allowsWelcomeLookup = true _report = State(initialValue: YearReportModel( services: session.services, details: initialDetails, @@ -44,6 +64,11 @@ struct MainTabs: View { _recordingWarning = State(initialValue: RecordingConfigurationWarningModel( preferences: recordingWarningSource.preferences, )) + _welcome = State(initialValue: LocationWelcomeModel( + services: session.services, + preferences: session.preferences, + now: session.now, + )) } var body: some View { @@ -73,12 +98,34 @@ struct MainTabs: View { } .badge(recordingWarning.isPresented ? 1 : 0) } + .accessibilityHidden(welcomePresentation != nil) + .tabViewBottomAccessory { + if let accessory = welcomeAccessory { + LocationWelcomeStatusAccessory(accessory: accessory) + } + } // Keep the tab bar fixed — don't minimize it as content scrolls. .tabBarMinimizeBehavior(.never) + .overlay { + LocationWelcomeOverlay( + presentation: welcomePresentation, + dismissAction: welcome.dismiss, + planStayAction: welcomePlanStayAction, + ) + } // Subscribe + pull once the scene is on screen, and again whenever it // returns to the foreground; cancel the subscription on background so a // backgrounded scene drives no refreshes. .task { await report.activate() } + .task(id: welcomeTaskID) { + guard allowsWelcomeLookup else { return } + guard welcomeTaskID.isEnabled else { + welcome.resetIfDisabled() + return + } + guard welcomeTaskID.isActive else { return } + await welcome.resolve() + } .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: @@ -91,10 +138,149 @@ struct MainTabs: View { break } } + .sheet(item: $plannedStayEditorTarget) { target in + PlannedStayEditor( + region: target.region, + model: report.forecasts, + driftThreshold: report.driftThreshold, + ) + } + } + + private var welcomeTaskID: WelcomeTaskID { + WelcomeTaskID( + isActive: scenePhase == .active, + isEnabled: report.showsLocationWelcome, + ) + } + + private var welcomePresentation: LocationWelcomeModel.Presentation? { + guard report.showsLocationWelcome else { return nil } + return welcome.presentation + } + + private var welcomeAccessory: LocationWelcomeModel.Accessory? { + guard report.showsLocationWelcome, welcomePresentation == nil else { return nil } + return welcome.accessory + } + + private var welcomePlanStayAction: ((Region) -> Void)? { + guard report.showsEstimatedTimeAndPlanning else { return nil } + return planStayFromWelcome + } + + private func planStayFromWelcome(_ region: Region) { + withAnimation(stylesheet.locationWelcome.motion.departure.animation) { + welcome.dismiss() + } completion: { + plannedStayEditorTarget = PlannedStayEditorTarget(region: region) + } } + + #if DEBUG + private init( + session: WhereSession, + initialDetails: YearReportDetails?, + selectedYear: Int, + welcome: LocationWelcomeModel, + selection: TabID, + ) { + let recordingWarningSource = RecordingConfigurationWarningModel.Source(session: session) + self.recordingWarningSource = recordingWarningSource + allowsWelcomeLookup = false + _report = State(initialValue: YearReportModel( + services: session.services, + details: initialDetails, + selectedYear: selectedYear, + preferences: session.preferences, + now: session.now, + )) + _recordingWarning = State(initialValue: RecordingConfigurationWarningModel( + preferences: recordingWarningSource.preferences, + )) + _welcome = State(initialValue: welcome) + _selection = State(initialValue: selection) + } + #endif } #if DEBUG + extension MainTabs: SnapshotProviding { + static var snapshots: [SnapshotCase] { + let configurations: [SnapshotConfiguration] = .phoneLightDark + [ + SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + SnapshotConfiguration(device: .iPhone, snapshotType: .accessibility), + ] + return [ + whereSnapshot( + name: "WelcomeLocations", + configurations: configurations, + measurementReadiness: .immediate, + ) { + welcomeSnapshot(selection: .locations) + }, + whereSnapshot( + name: "WelcomeYear", + configurations: configurations, + measurementReadiness: .immediate, + ) { + welcomeSnapshot(selection: .year) + }, + whereSnapshot( + name: "WelcomeLocating", + configurations: configurations, + measurementReadiness: .immediate, + ) { + accessorySnapshot(actionRequired: false) + }, + whereSnapshot( + name: "WelcomeActionRequired", + configurations: configurations, + measurementReadiness: .immediate, + ) { + accessorySnapshot(actionRequired: true) + }, + ] + } + + private static func welcomeSnapshot(selection: TabID) -> some View { + snapshot(selection: selection) { welcome in + welcome.presentForTesting(region: .newYork, greeting: .returnVisit) + } + } + + private static func accessorySnapshot(actionRequired: Bool) -> some View { + snapshot(selection: .year) { welcome in + if actionRequired { + welcome.showPreciseLocationActionForTesting() + } else { + welcome.showLocatingForTesting() + } + } + } + + private static func snapshot( + selection: TabID, + configure: (LocationWelcomeModel) -> Void, + ) -> some View { + let session = PreviewSupport.loadedSession() + let welcome = LocationWelcomeModel( + services: session.services, + preferences: session.preferences, + now: session.now, + ) + configure(welcome) + return MainTabs( + session: session, + initialDetails: PreviewSupport.sampleYearReportDetails(), + selectedYear: PreviewSupport.year, + welcome: welcome, + selection: selection, + ) + .environment(session) + } + } + private struct MainTabsPreview: View { private let session = PreviewSupport.loadedSession() diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 569261ab1..057fe64a4 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -502,8 +502,7 @@ public final class YearReportModel { /// GPS fix for where the entry was made. A missing fix is recorded honestly /// (`location == nil`) rather than blocking the entry. private func makeEntryAudit(note: String?) async -> ManualEntryAudit { - let sample = await services.ingestor.currentLocation() - let location = sample.map { sample in + let location = await services.ingestor.currentLocation().sample.map { sample in CapturedLocation( coordinate: sample.coordinate, horizontalAccuracy: sample.horizontalAccuracy, diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift index 1f955d081..ff463cf6e 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift @@ -1,8 +1,9 @@ +import Foundation import Observation import RegionKit import WhereCore -/// Presentation state for the live-region welcome on the Locations tab. +/// App-wide acquisition and presentation state for the live-region welcome. @MainActor @Observable public final class LocationWelcomeModel { @@ -16,52 +17,187 @@ public final class LocationWelcomeModel { public let greeting: Greeting } - public private(set) var presentation: Presentation? + enum ActionRequired: Equatable { + case locationAccess + case preciseLocation + } + + enum State: Equatable { + case idle + case locating(showsProgress: Bool) + case actionRequired(ActionRequired) + case presenting(Presentation) + } + + enum Accessory: Equatable { + case locating + case actionRequired(ActionRequired) + } + + private enum ResolutionEvent { + case delayElapsed + case delayCancelled + case resolved(CurrentRegionResolution) + } + + private(set) var state: State = .idle + + var presentation: Presentation? { + guard case let .presenting(presentation) = state else { return nil } + return presentation + } + + var accessory: Accessory? { + switch state { + case .locating(showsProgress: true): .locating + case let .actionRequired(action): .actionRequired(action) + case .idle, .locating(showsProgress: false), .presenting: nil + } + } private let resolver: CurrentRegionResolver private let preferences: WherePreferences + private let now: @Sendable () -> Date + private let findingDelay: Duration private var resolutionSequence: UInt64 = 0 - init(services: WhereServices, preferences: WherePreferences) { + init( + services: WhereServices, + preferences: WherePreferences, + now: @escaping @Sendable () -> Date, + findingDelay: Duration = .seconds(1), + ) { resolver = services.currentRegion self.preferences = preferences + self.now = now + self.findingDelay = findingDelay } - /// Resolves a fresh welcome while the Locations root is visible. + /// Performs one independent foreground lookup. A later lookup supersedes + /// any earlier result that reaches the model out of order. func resolve() async { guard preferences.showsLocationWelcome, presentation == nil else { return } let (sequence, overflow) = resolutionSequence.addingReportingOverflow(1) precondition(!overflow, "Location welcome resolution sequence exhausted UInt64.") resolutionSequence = sequence + state = .locating(showsProgress: false) + + let resolution = await withTaskGroup( + of: ResolutionEvent.self, + returning: CurrentRegionResolution.self, + ) { group in + let resolver = resolver + let now = now + group.addTask { + await .resolved(resolver.resolve(now: now())) + } + group.addTask { [findingDelay] in + do { + try await Task.sleep(for: findingDelay) + return .delayElapsed + } catch { + return .delayCancelled + } + } + + for await event in group { + switch event { + case .delayElapsed: + guard + !Task.isCancelled, + sequence == resolutionSequence, + state == .locating(showsProgress: false) + else { continue } + state = .locating(showsProgress: true) + case .delayCancelled: + continue + case let .resolved(resolution): + group.cancelAll() + return resolution + } + } + return .unavailable(.location(.cancellation)) + } - guard let region = await resolver.resolve() else { return } guard !Task.isCancelled, preferences.showsLocationWelcome, sequence == resolutionSequence, presentation == nil - else { return } - let previous = preferences.lastWelcomedRegion - guard region != previous else { return } - presentation = Presentation( - region: region, - greeting: previous == nil ? .first : .returnVisit, - ) + else { + if sequence == resolutionSequence, presentation == nil { state = .idle } + return + } + + switch resolution { + case let .resolved(region): + let previous = preferences.lastWelcomedRegion + guard region != previous else { + state = .idle + return + } + state = .presenting(Presentation( + region: region, + greeting: previous == nil ? .first : .returnVisit, + )) + case let .unavailable(reason): + state = actionRequired(for: reason).map(State.actionRequired) ?? .idle + } } func dismiss() { guard let presentation else { return } preferences.lastWelcomedRegion = presentation.region - self.presentation = nil + state = .idle + } + + func resetIfDisabled() { + guard preferences.showsLocationWelcome == false else { return } + let (sequence, overflow) = resolutionSequence.addingReportingOverflow(1) + precondition(!overflow, "Location welcome resolution sequence exhausted UInt64.") + resolutionSequence = sequence + state = .idle + } + + private func actionRequired( + for reason: CurrentRegionResolution.UnavailableReason, + ) -> ActionRequired? { + guard case let .location(locationReason) = reason else { return nil } + switch locationReason { + case .preciseLocationDisabled: + return .preciseLocation + case let .authorizationUnavailable(status): + switch status { + case .denied, .restricted: return .locationAccess + case .always, .whenInUse, .notDetermined: return nil + } + case .timeout, .providerFailure, .cancellation: + return nil + } } #if DEBUG - /// Seeds a deterministic state for previews and image snapshots. + /// Seeds a deterministic presentation for previews and image snapshots. @_spi(Testing) public func presentForTesting( region: Region, greeting: Presentation.Greeting, ) { - presentation = Presentation(region: region, greeting: greeting) + state = .presenting(Presentation(region: region, greeting: greeting)) + } + + /// Seeds the delayed locating accessory without running Core Location. + @_spi(Testing) public func showLocatingForTesting() { + state = .locating(showsProgress: true) + } + + /// Seeds an actionable location-access failure without Core Location. + @_spi(Testing) public func showLocationAccessActionForTesting() { + state = .actionRequired(.locationAccess) + } + + /// Seeds an actionable Precise Location failure without Core Location. + @_spi(Testing) public func showPreciseLocationActionForTesting() { + state = .actionRequired(.preciseLocation) } #endif } diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift index f8e78d350..8292ac616 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift @@ -2,7 +2,7 @@ import RegionKit import SwiftUI import UIKit -/// The modal scrim and adaptive placement for a Locations welcome card. +/// The modal scrim and adaptive placement for an app-wide welcome card. struct LocationWelcomeOverlay: View { let presentation: LocationWelcomeModel.Presentation? let dismissAction: () -> Void diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift new file mode 100644 index 000000000..691c33076 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift @@ -0,0 +1,81 @@ +import SFSafeSymbols +import SwiftUI + +/// Compact acquisition or recovery status hosted above the app's tab bar. +struct LocationWelcomeStatusAccessory: View { + let accessory: LocationWelcomeModel.Accessory + + @Environment(\.openURL) private var openURL + @Environment(\.stylesheet) private var stylesheet + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @MotionIsStatic private var motionIsStatic + + var body: some View { + Group { + switch accessory { + case .locating: + HStack(spacing: style.contentSpacing) { + if motionIsStatic { + Image(systemSymbol: .locationFill) + .font(.system(size: style.symbolSize, weight: .semibold)) + .accessibilityHidden(true) + } else { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Text(String(localized: .locationWelcomeFinding)) + .font(style.titleFont) + } + case let .actionRequired(action): + Button(action: openSettings) { + HStack(spacing: style.contentSpacing) { + Image(systemSymbol: .locationSlashFill) + .font(.system(size: style.symbolSize, weight: .semibold)) + .accessibilityHidden(true) + Text(displayedMessage(for: action)) + .font(style.titleFont) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + .accessibilityLabel(message(for: action)) + } + } + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var style: WhereStylesheet.LocationWelcomeStyle.Accessory { + stylesheet.locationWelcome.accessory + } + + private func message(for action: LocationWelcomeModel.ActionRequired) -> String { + switch action { + case .locationAccess: String(localized: .locationWelcomeAccessMessage) + case .preciseLocation: String(localized: .locationWelcomePreciseMessage) + } + } + + private func displayedMessage(for action: LocationWelcomeModel.ActionRequired) -> String { + dynamicTypeSize.isAccessibilitySize ? String(localized: .tabSettings) : message(for: action) + } + + private func openSettings() { + openSystemSettings(openURL) + } +} + +#if DEBUG + #Preview("Finding") { + LocationWelcomeStatusAccessory(accessory: .locating) + .whereBroadwayRoot() + } + + #Preview("Action required") { + LocationWelcomeStatusAccessory(accessory: .actionRequired(.preciseLocation)) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index 7d0d3fa06..a57e99e7a 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -14,10 +14,8 @@ 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 @State private var planning = LocationsPlanningModel() /// Drives the region cards' tilt-reactive light sheen. Started/stopped @@ -37,40 +35,10 @@ struct LocationsView: View { && !showingResolution && plannedStayEditorTarget == nil && !planning.isShowingError - && welcomePresentation == nil - } - - private var isWelcomeLookupActive: Bool { - 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 } 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, @@ -83,8 +51,6 @@ struct LocationsView: View { NavigationStack { screen .navigationBarTitleDisplayMode(.inline) - .onAppear { isLocationsSurfaceVisible = true } - .onDisappear { isLocationsSurfaceVisible = false } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { // Resolve stays immediately left of the stable planning @@ -110,18 +76,6 @@ struct LocationsView: View { } } } - .accessibilityHidden(welcomePresentation != nil) - .overlay { - LocationWelcomeOverlay( - presentation: welcomePresentation, - dismissAction: welcome.dismiss, - planStayAction: welcomePlanStayAction, - ) - } - .task(id: isWelcomeLookupActive) { - guard isWelcomeLookupActive else { return } - await welcome.resolve() - } .onAppear { tilt.start() } .onDisappear { tilt.stop() } .sheet(isPresented: $showingResolution) { @@ -301,14 +255,6 @@ struct LocationsView: View { plannedStayEditorTarget = PlannedStayEditorTarget(region: region) } - private func planStayFromWelcome(_ region: Region) { - withAnimation(stylesheet.locationWelcome.motion.departure.animation) { - welcome.dismiss() - } completion: { - editPlannedStay(region) - } - } - private func clearPlannedStay() { Task { await planning.clear(using: report.forecasts.clear) @@ -444,39 +390,11 @@ 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/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 84b8fa563..2b8a54adf 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3571,6 +3571,18 @@ } } }, + "locationWelcome.access.message" : { + "comment" : "Explains why the app needs location access to identify the current region.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location off — Open Settings" + } + } + } + }, "locationWelcome.dismiss" : { "comment" : "Accessibility label for the close button on the live-region welcome card.", "extractionState" : "manual", @@ -3583,6 +3595,18 @@ } } }, + "locationWelcome.finding" : { + "comment" : "Temporary status shown while resolving the device's current tracked region.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Finding your current region…" + } + } + } + }, "locationWelcome.firstTitle" : { "comment" : "Title shown for the first live tracked region. The argument is the localized region name.", "extractionState" : "manual", @@ -3607,6 +3631,18 @@ } } }, + "locationWelcome.precise.message" : { + "comment" : "Explains why Precise Location is needed for confident region attribution.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Precise off — Open Settings" + } + } + } + }, "locationWelcome.returnTitle" : { "comment" : "Title shown after the user moves to another tracked region. The argument is the localized region name.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index ceca2798e..f5abbe4e9 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -115,6 +115,7 @@ extension WhereStylesheet { var glow: Shadow var lift: Shadow var close: Close + var accessory: Accessory var motion: Motion struct Shadow: Equatable { @@ -130,6 +131,14 @@ extension WhereStylesheet { var lift: Shadow } + struct Accessory: Equatable { + var contentSpacing: CGFloat + var horizontalPadding: CGFloat + var verticalPadding: CGFloat + var symbolSize: CGFloat + var titleFont: Font + } + struct Motion: Equatable { var arrival: Movement var departure: Movement @@ -219,6 +228,13 @@ extension WhereStylesheet { glow: .init(opacity: 0.28, radius: 8), lift: .init(opacity: 0.22, radius: 5, offsetY: 3), ), + accessory: Accessory( + contentSpacing: 10, + horizontalPadding: 12, + verticalPadding: 8, + symbolSize: 16, + titleFont: .subheadline.weight(.semibold), + ), motion: .standard, ) } diff --git a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift index f6b8176f5..cc4e862ac 100644 --- a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift +++ b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift @@ -6,6 +6,8 @@ import Testing @MainActor struct LocationWelcomeModelTests { + private static let now = Date(timeIntervalSinceReferenceDate: 10000) + @Test func firstResolvedRegionPresentsAFirstGreeting() async throws { let fixture = try await fixture(region: .california) @@ -22,6 +24,7 @@ struct LocationWelcomeModelTests { let relaunched = LocationWelcomeModel( services: fixture.services, preferences: fixture.preferences, + now: { Self.now }, ) await relaunched.resolve() @@ -89,7 +92,11 @@ struct LocationWelcomeModelTests { 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 model = LocationWelcomeModel( + services: services, + preferences: preferences, + now: { Self.now }, + ) let task = Task { await model.resolve() } await source.waitUntilRequestCount(1) @@ -108,7 +115,11 @@ struct LocationWelcomeModelTests { 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 model = LocationWelcomeModel( + services: services, + preferences: preferences, + now: { Self.now }, + ) let task = Task { await model.resolve() } await source.waitUntilRequestCount(1) @@ -122,6 +133,89 @@ struct LocationWelcomeModelTests { #expect(model.presentation == nil) } + @Test func delayedAcquisitionShowsLocatingAccessory() 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, + now: { Self.now }, + findingDelay: .zero, + ) + let task = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + await waitUntil { model.accessory == .locating } + + #expect(model.state == .locating(showsProgress: true)) + + task.cancel() + await source.resolveRequest(at: 0, with: .unavailable(.cancellation)) + await task.value + } + + @Test func preciseLocationFailureRequiresSettingsAction() async throws { + let fixture = try await fixture(result: .unavailable(.preciseLocationDisabled)) + + await fixture.model.resolve() + + #expect(fixture.model.accessory == .actionRequired(.preciseLocation)) + } + + @Test(arguments: [LocationAuthorizationStatus.denied, .restricted]) + func deniedOrRestrictedLocationRequiresSettingsAction( + status: LocationAuthorizationStatus, + ) async throws { + let fixture = try await fixture( + result: .unavailable(.authorizationUnavailable(status)), + ) + + await fixture.model.resolve() + + #expect(fixture.model.accessory == .actionRequired(.locationAccess)) + } + + @Test( + arguments: [ + CurrentLocationResult.UnavailableReason.timeout, + .providerFailure, + .cancellation, + ], + ) + func transientFailuresReturnSilentlyToIdle( + reason: CurrentLocationResult.UnavailableReason, + ) async throws { + let fixture = try await fixture(result: .unavailable(reason)) + + await fixture.model.resolve() + + #expect(fixture.model.state == .idle) + } + + @Test func newerResolutionWinsWhenRequestsFinishOutOfOrder() 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, + now: { Self.now }, + ) + let first = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + let second = Task { await model.resolve() } + await source.waitUntilRequestCount(2) + + try await source.resolveRequest(at: 1, with: Self.sample(region: .newYork)) + await second.value + try await source.resolveRequest(at: 0, with: Self.sample(region: .california)) + await first.value + + #expect(model.presentation == .init(region: .newYork, greeting: .first)) + } + private func fixture( region: Region, preferences: WherePreferences = WherePreferences(store: InMemoryKeyValueStore()), @@ -131,6 +225,29 @@ struct LocationWelcomeModelTests { return fixture } + private func fixture(result: CurrentLocationResult) async throws -> Fixture { + let source = ScriptedLocationSource() + source.setNextRequestedLocationResult(result) + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try Self.services(locationSource: source) + try await services.ingestor.authorizeRecording() + return Fixture( + model: LocationWelcomeModel( + services: services, + preferences: preferences, + now: { Self.now }, + ), + services: services, + preferences: preferences, + ) + } + + private func waitUntil(_ condition: @MainActor () -> Bool) async { + while condition() == false { + await Task.yield() + } + } + private func fixtureWithoutRecording( region: Region, preferences: WherePreferences = WherePreferences(store: InMemoryKeyValueStore()), @@ -139,7 +256,11 @@ struct LocationWelcomeModelTests { try source.setNextRequestedLocation(Self.sample(region: region)) let services = try Self.services(locationSource: source) return Fixture( - model: LocationWelcomeModel(services: services, preferences: preferences), + model: LocationWelcomeModel( + services: services, + preferences: preferences, + now: { Self.now }, + ), services: services, preferences: preferences, ) @@ -159,7 +280,7 @@ struct LocationWelcomeModelTests { ] let coordinate = try #require(coordinates[region]) return LocationSample( - timestamp: Date(timeIntervalSinceReferenceDate: 0), + timestamp: now, coordinate: coordinate, horizontalAccuracy: 5, source: .gpsSignificantChange, diff --git a/Where/WhereUI/Tests/Support/GatedCurrentLocationSource.swift b/Where/WhereUI/Tests/Support/GatedCurrentLocationSource.swift index 01d944e8b..9883e5ce6 100644 --- a/Where/WhereUI/Tests/Support/GatedCurrentLocationSource.swift +++ b/Where/WhereUI/Tests/Support/GatedCurrentLocationSource.swift @@ -12,13 +12,13 @@ actor GatedCurrentLocationSource: LocationSource { continuation.finish() } - private var requests: [CheckedContinuation] = [] + private var requests: [CheckedContinuation] = [] private var requestCountWaiters: [Int: [CheckedContinuation]] = [:] func start() async {} func stop() async {} - func requestCurrentLocation() async -> LocationSample? { + func requestCurrentLocation() async -> CurrentLocationResult { await withCheckedContinuation { continuation in requests.append(continuation) let count = requests.count @@ -40,6 +40,11 @@ actor GatedCurrentLocationSource: LocationSource { } func resolveRequest(at index: Int, with sample: LocationSample?) { - requests.remove(at: index).resume(returning: sample) + requests.remove(at: index).resume(returning: sample.map(CurrentLocationResult.success) + ?? .unavailable(.timeout)) + } + + func resolveRequest(at index: Int, with result: CurrentLocationResult) { + requests.remove(at: index).resume(returning: result) } } diff --git a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift index a7566e4a9..7240bd099 100644 --- a/Where/WhereUI/Tests/WhereSessionTrackingTests.swift +++ b/Where/WhereUI/Tests/WhereSessionTrackingTests.swift @@ -366,8 +366,8 @@ private final class TrackingLocationSource: LocationSource, @unchecked Sendable } } - func requestCurrentLocation() async -> LocationSample? { - nil + func requestCurrentLocation() async -> CurrentLocationResult { + .unavailable(.timeout) } func currentAuthorization() async -> LocationAuthorizationStatus { @@ -397,8 +397,8 @@ private final class SuspendedPermissionLocationSource: LocationSource, @unchecke func start() async {} func stop() async {} - func requestCurrentLocation() async -> LocationSample? { - nil + func requestCurrentLocation() async -> CurrentLocationResult { + .unavailable(.timeout) } func currentAuthorization() async -> LocationAuthorizationStatus { diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index f903af7b4..00da9da4c 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -60,6 +60,11 @@ struct WhereStylesheetTests { #expect(welcome.close.tintOpacity == 0.24) #expect(welcome.close.glow == .init(opacity: 0.28, radius: 8)) #expect(welcome.close.lift == .init(opacity: 0.22, radius: 5, offsetY: 3)) + #expect(welcome.accessory.contentSpacing == 10) + #expect(welcome.accessory.horizontalPadding == 12) + #expect(welcome.accessory.verticalPadding == 8) + #expect(welcome.accessory.symbolSize == 16) + #expect(welcome.accessory.titleFont == .subheadline.weight(.semibold)) #expect(welcome.motion == .standard) #expect(welcome.motion.arrival == .init( animation: .spring(duration: 0.3, bounce: 0.28), From 9a61dfbe5f34a45e21ebaa6d26d2946ae11f6f5a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 13 Sep 2026 20:22:16 -0400 Subject: [PATCH 02/11] Make location recovery accessory accessible --- ...omeActionRequired_iPhone_accessibility.png | 4 +- ...nTabs.WelcomeActionRequired_iPhone_ax5.png | 4 +- .../LocationWelcomeStatusAccessory.swift | 76 +++++++++++-------- .../Sources/Resources/Localizable.xcstrings | 24 ++++++ .../Sources/Shared/WhereStylesheet.swift | 2 + .../WhereUI/Tests/WhereStylesheetTests.swift | 1 + 6 files changed, 75 insertions(+), 36 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png index 952c311c9..3c5889ebd 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbfa930ccff34aa6db885710a580cef2db71657e30196218cf2c34438f93b628 -size 647755 +oid sha256:65213ddf0c28134fbe708a5185626fd29aae3c744929d32e5a1a552e255ab4f8 +size 647053 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png index c0ca6736e..62b27336e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63117e30a127f0fdd120e988a916c293a97ee785b20184e894f16f0741d4eb86 -size 747032 +oid sha256:dcbb8a64823f19c431c75e0f90367e89228043b344cab8199905ce4886840f95 +size 749045 diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift index 691c33076..9faa11152 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeStatusAccessory.swift @@ -11,41 +11,46 @@ struct LocationWelcomeStatusAccessory: View { @MotionIsStatic private var motionIsStatic var body: some View { - Group { - switch accessory { - case .locating: + switch accessory { + case .locating: + HStack(spacing: style.contentSpacing) { + if motionIsStatic { + Image(systemSymbol: .locationFill) + .font(.system(size: style.symbolSize, weight: .semibold)) + .accessibilityHidden(true) + } else { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + Text(String(localized: .locationWelcomeFinding)) + .font(style.titleFont) + } + .padding(.horizontal, style.horizontalPadding) + .padding(.vertical, style.verticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + case let .actionRequired(action): + Button(action: openSettings) { HStack(spacing: style.contentSpacing) { - if motionIsStatic { - Image(systemSymbol: .locationFill) - .font(.system(size: style.symbolSize, weight: .semibold)) - .accessibilityHidden(true) - } else { - ProgressView() - .controlSize(.small) - .accessibilityHidden(true) - } - Text(String(localized: .locationWelcomeFinding)) + Image(systemSymbol: .locationSlashFill) + .font(.system(size: style.symbolSize, weight: .semibold)) + .accessibilityHidden(true) + Text(displayedMessage(for: action)) .font(style.titleFont) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) } - case let .actionRequired(action): - Button(action: openSettings) { - HStack(spacing: style.contentSpacing) { - Image(systemSymbol: .locationSlashFill) - .font(.system(size: style.symbolSize, weight: .semibold)) - .accessibilityHidden(true) - Text(displayedMessage(for: action)) - .font(style.titleFont) - .multilineTextAlignment(.leading) - Spacer(minLength: 0) - } - } - .buttonStyle(.plain) - .accessibilityLabel(message(for: action)) - } + .padding(.horizontal, style.horizontalPadding) + .frame( + maxWidth: .infinity, + minHeight: style.minimumActionHeight, + alignment: .leading, + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(message(for: action)) } - .padding(.horizontal, style.horizontalPadding) - .padding(.vertical, style.verticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) } private var style: WhereStylesheet.LocationWelcomeStyle.Accessory { @@ -60,7 +65,14 @@ struct LocationWelcomeStatusAccessory: View { } private func displayedMessage(for action: LocationWelcomeModel.ActionRequired) -> String { - dynamicTypeSize.isAccessibilitySize ? String(localized: .tabSettings) : message(for: action) + dynamicTypeSize.isAccessibilitySize ? compactMessage(for: action) : message(for: action) + } + + private func compactMessage(for action: LocationWelcomeModel.ActionRequired) -> String { + switch action { + case .locationAccess: String(localized: .locationWelcomeAccessCompact) + case .preciseLocation: String(localized: .locationWelcomePreciseCompact) + } } private func openSettings() { diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 2b8a54adf..89163e7fa 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3571,6 +3571,18 @@ } } }, + "locationWelcome.access.compact" : { + "comment" : "Compact reason shown at accessibility text sizes when location access is unavailable.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Location off — Settings" + } + } + } + }, "locationWelcome.access.message" : { "comment" : "Explains why the app needs location access to identify the current region.", "extractionState" : "manual", @@ -3631,6 +3643,18 @@ } } }, + "locationWelcome.precise.compact" : { + "comment" : "Compact reason shown at accessibility text sizes when Precise Location is disabled.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Precise off — Settings" + } + } + } + }, "locationWelcome.precise.message" : { "comment" : "Explains why Precise Location is needed for confident region attribution.", "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index f5abbe4e9..09709d18b 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -135,6 +135,7 @@ extension WhereStylesheet { var contentSpacing: CGFloat var horizontalPadding: CGFloat var verticalPadding: CGFloat + var minimumActionHeight: CGFloat var symbolSize: CGFloat var titleFont: Font } @@ -232,6 +233,7 @@ extension WhereStylesheet { contentSpacing: 10, horizontalPadding: 12, verticalPadding: 8, + minimumActionHeight: 44, symbolSize: 16, titleFont: .subheadline.weight(.semibold), ), diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 00da9da4c..1670ffabe 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -63,6 +63,7 @@ struct WhereStylesheetTests { #expect(welcome.accessory.contentSpacing == 10) #expect(welcome.accessory.horizontalPadding == 12) #expect(welcome.accessory.verticalPadding == 8) + #expect(welcome.accessory.minimumActionHeight == 44) #expect(welcome.accessory.symbolSize == 16) #expect(welcome.accessory.titleFont == .subheadline.weight(.semibold)) #expect(welcome.motion == .standard) From c1e376c044370499e16acdd54e77f1f1a0014173 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 14 Sep 2026 08:25:11 -0400 Subject: [PATCH 03/11] Fix conditional region welcome accessory Enable the system tab accessory only while live-region status is visible, so idle and welcome states do not reserve an empty bar. Disable welcome lookup in unrelated root fixtures and give large-text calendar snapshots time to reach their final scroll position. --- ...nTabs.WelcomeActionRequired_iPhone_ax5.png | 4 +- .../mainTabs.WelcomeLocating_iPhone_ax5.png | 4 +- .../mainTabs.WelcomeLocations_iPhone.png | 4 +- ....WelcomeLocations_iPhone_accessibility.png | 4 +- .../mainTabs.WelcomeLocations_iPhone_dark.png | 4 +- .../mainTabs.WelcomeYear_iPhone.png | 4 +- ...nTabs.WelcomeYear_iPhone_accessibility.png | 4 +- .../mainTabs.WelcomeYear_iPhone_ax5.png | 4 +- .../mainTabs.WelcomeYear_iPhone_dark.png | 4 +- Where/WhereUI/Sources/MainTabs.swift | 52 +++++++++++++++---- .../LocationWelcomeAccessoryModifier.swift | 22 ++++++++ Where/WhereUI/Sources/RootView.swift | 11 +++- 12 files changed, 90 insertions(+), 31 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png index 62b27336e..f578c58d8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dcbb8a64823f19c431c75e0f90367e89228043b344cab8199905ce4886840f95 -size 749045 +oid sha256:0de78de170b8fe235e865bb0509266e30df67b83364222dbecc4df4341b63454 +size 701804 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png index b5e803ab7..bf5c5a572 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d33831121a7abaff3b81964dd6acca722f83142e09bdf2fc777b5b5898bd1109 -size 749760 +oid sha256:9920a5f4f45a7493eb88576c27076e69ede0b43bde3e9dd2733e88fe6c588936 +size 702270 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png index 0c3b3dd43..6e1524191 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:39658ffd42277265027a0dfda24c6865f5dc1fdf404bd280cf7c62a2bc7983ec -size 1051134 +oid sha256:875ffd945db9f8f4eff53761b035f4ee9a0d80b28c0cec0f3dfba6d7bb5ef471 +size 1031894 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png index 23fa40fe9..18f9fb1d7 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4186726b4dd42a1de0bbbd16e45500b13e42d89bd46d6f1613fae243031c123d -size 926142 +oid sha256:3824b30e1786a5ae9d1eb80f332c538cdc94a0a7d47bf4340aad2307b1a06b41 +size 914021 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png index 7072cf958..235118837 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocations_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6651598772656be913cf175e7ba91256424e6044870d31afe8f59e7c823df0d7 -size 810318 +oid sha256:2161ade105f31e655f24f3d778fe649263b253552be256a1828955f2e2b85f80 +size 794734 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png index 72ff8da42..0fb060960 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f261052884173e18e0c0425b748fcbb499f9060a18d846af50db2dbbce1f897 -size 1443293 +oid sha256:7768eed0683462de7d0e7cdd674096f278d563a77eafcfd4eedd3733bec57bc7 +size 1381989 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png index d1b21b47c..8e5da8d13 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:652ee577102154f0c47cfb82cd1aa3f6ecc30e8f8c2ed1e9375626e078a50ff2 -size 1096131 +oid sha256:25d2901f745fd07f5304a4a8f3c4bc5b36038b13d293bc23910b3bd89a2848d1 +size 1062340 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png index d724f078a..a6c4bf587 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5af839965eb7898b9e3043b68f7b6f1a3ee6b947cba1d1444926910cb26bd175 -size 2373009 +oid sha256:9734d783ecd8c364bc7a2d7682b601707c24e6881479f5fd5cb2a5fa92212f3b +size 2382886 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png index 62d823360..707fefd15 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4354011fa2bf5f4a47e23c3228b542efbe2543324350c8f0c1143efc8d189776 -size 1423680 +oid sha256:b60fd3e650ec00df974d191d0c0a392a6e0ecb8437b02466930029ef618d7316 +size 1339627 diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index fccc6443c..0c66cca2a 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -99,11 +99,7 @@ struct MainTabs: View { .badge(recordingWarning.isPresented ? 1 : 0) } .accessibilityHidden(welcomePresentation != nil) - .tabViewBottomAccessory { - if let accessory = welcomeAccessory { - LocationWelcomeStatusAccessory(accessory: accessory) - } - } + .modifier(LocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) // Keep the tab bar fixed — don't minimize it as content scrolls. .tabBarMinimizeBehavior(.never) .overlay { @@ -207,36 +203,70 @@ struct MainTabs: View { #if DEBUG extension MainTabs: SnapshotProviding { static var snapshots: [SnapshotCase] { - let configurations: [SnapshotConfiguration] = .phoneLightDark + [ - SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + let fastConfigurations: [SnapshotConfiguration] = .phoneLightDark + [ SnapshotConfiguration(device: .iPhone, snapshotType: .accessibility), ] + let largeTypeConfigurations = [ + SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + ] return [ whereSnapshot( name: "WelcomeLocations", - configurations: configurations, + configurations: fastConfigurations, + measurementReadiness: .immediate, + ) { + welcomeSnapshot(selection: .locations) + }, + whereSnapshot( + name: "WelcomeYear", + configurations: fastConfigurations, + measurementReadiness: .immediate, + ) { + welcomeSnapshot(selection: .year) + }, + whereSnapshot( + name: "WelcomeLocating", + configurations: fastConfigurations, + measurementReadiness: .immediate, + ) { + accessorySnapshot(actionRequired: false) + }, + whereSnapshot( + name: "WelcomeActionRequired", + configurations: fastConfigurations, + measurementReadiness: .immediate, + ) { + accessorySnapshot(actionRequired: true) + }, + whereSnapshot( + name: "WelcomeLocations", + configurations: largeTypeConfigurations, measurementReadiness: .immediate, + settle: .settledAtLeast(minDuration: 1.0), ) { welcomeSnapshot(selection: .locations) }, whereSnapshot( name: "WelcomeYear", - configurations: configurations, + configurations: largeTypeConfigurations, measurementReadiness: .immediate, + settle: .settledAtLeast(minDuration: 1.0), ) { welcomeSnapshot(selection: .year) }, whereSnapshot( name: "WelcomeLocating", - configurations: configurations, + configurations: largeTypeConfigurations, measurementReadiness: .immediate, + settle: .settledAtLeast(minDuration: 1.0), ) { accessorySnapshot(actionRequired: false) }, whereSnapshot( name: "WelcomeActionRequired", - configurations: configurations, + configurations: largeTypeConfigurations, measurementReadiness: .immediate, + settle: .settledAtLeast(minDuration: 1.0), ) { accessorySnapshot(actionRequired: true) }, diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift new file mode 100644 index 000000000..ae301e0b8 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// Adds the system tab accessory only while live-region status is visible. +struct LocationWelcomeAccessoryModifier: ViewModifier { + let accessory: LocationWelcomeModel.Accessory? + + func body(content: Content) -> some View { + if #available(iOS 26.1, *) { + content.tabViewBottomAccessory(isEnabled: accessory != nil) { + if let accessory { + LocationWelcomeStatusAccessory(accessory: accessory) + } + } + } else if let accessory { + content.tabViewBottomAccessory { + LocationWelcomeStatusAccessory(accessory: accessory) + } + } else { + content + } + } +} diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index c31db333b..6c41eaef1 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -349,9 +349,11 @@ public struct RootView: View { /// notification), hence the generous floor — see the flakiness ledger in /// `Where/TODOs.md`. public static var snapshots: [SnapshotCase] { - let model = PreviewSupport.loadedModel() + let model = welcomeDisabled(PreviewSupport.loadedModel()) let launcher = WhereLaunch.makeLauncher(model: model, reason: .userForeground) - let recordingWarningModel = PreviewSupport.recordingConfigurationWarningAppModel() + let recordingWarningModel = welcomeDisabled( + PreviewSupport.recordingConfigurationWarningAppModel(), + ) let recordingWarningLauncher = WhereLaunch.makeLauncher( model: recordingWarningModel, reason: .userForeground, @@ -383,6 +385,11 @@ public struct RootView: View { ) } } + + private static func welcomeDisabled(_ model: WhereModel) -> WhereModel { + model.preferences.showsLocationWelcome = false + return model + } } // The from-scratch launch preview (splash → onboarding) — the matrix pins From 6711e4b238ae57c030a017fdc64153d5910a68fc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 14 Sep 2026 20:50:49 -0400 Subject: [PATCH 04/11] Pin preview session clock --- .../mainTabs.WelcomeActionRequired_iPhone.png | 4 ++-- ...mainTabs.WelcomeActionRequired_iPhone_accessibility.png | 4 ++-- .../mainTabs.WelcomeActionRequired_iPhone_ax5.png | 4 ++-- .../mainTabs.WelcomeActionRequired_iPhone_dark.png | 4 ++-- .../mainTabs.WelcomeLocating_iPhone.png | 4 ++-- .../mainTabs.WelcomeLocating_iPhone_accessibility.png | 4 ++-- .../mainTabs.WelcomeLocating_iPhone_ax5.png | 4 ++-- .../mainTabs.WelcomeLocating_iPhone_dark.png | 4 ++-- .../MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png | 4 ++-- .../mainTabs.WelcomeYear_iPhone_accessibility.png | 4 ++-- .../mainTabs.WelcomeYear_iPhone_ax5.png | 4 ++-- .../mainTabs.WelcomeYear_iPhone_dark.png | 4 ++-- Where/WhereUI/Sources/Preview/PreviewSupport.swift | 7 ++++++- 13 files changed, 30 insertions(+), 25 deletions(-) diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png index cb7b7901e..9fa49dce2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac57110825bb8848480c83099eade66b701468acf196f370a2c3e2140aa0cc93 -size 678841 +oid sha256:b69fa723f8202113081dc8107b2786632d4a46181f4db09070855b24adb75c70 +size 490092 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png index 3c5889ebd..f68708e86 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65213ddf0c28134fbe708a5185626fd29aae3c744929d32e5a1a552e255ab4f8 -size 647053 +oid sha256:dec35403c05cb22ee0090526a5d0337c5e17d319c1184a567cf638374afb23cb +size 653132 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png index f578c58d8..47d4b0d95 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0de78de170b8fe235e865bb0509266e30df67b83364222dbecc4df4341b63454 -size 701804 +oid sha256:bdbbdb0cf6b70e93163673facc1d21812b76567d66127422faa99b8d76d7fa12 +size 828285 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png index 5d2a9ee82..0ca7d2950 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeActionRequired_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:595ce5a52be93a6f2d29c7fbc8229ab9120cd485e726ae4c7fd4163a64561457 -size 890468 +oid sha256:47036dcf9d825f1d2194b1000e954b471ad25a97f4887ef52472bd37e814575b +size 805967 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png index edea56cb7..8eba17798 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82f2b208a44ac131fcdf8b60c35d2684fb12b0ab8939494ccfcfd834df3ca258 -size 677804 +oid sha256:f8f810bc3c903a0eee470aab5a858351c1f4edcc5ef0c96689b43416ac36699f +size 487933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png index e09cd8660..09ec51ec5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f52110c6e3ac7f060a5dd19a76dbf9be7ac53a4566937a948d1a5bd291a745a -size 644942 +oid sha256:8c0a3c34626f6bfd0425358fffa074c2b0f7be62c6cdd06e3b2be7df906665d3 +size 651268 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png index bf5c5a572..fe9a39bbb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9920a5f4f45a7493eb88576c27076e69ede0b43bde3e9dd2733e88fe6c588936 -size 702270 +oid sha256:4d54e86548441d4add318628172e62616b0a943d2159c2b168738afa5b647a9a +size 829246 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png index bd91ead8e..505a428c9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeLocating_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f8871a7be2ccc2698ad97a2f99d64803f4a54cf7c9b4f75b55c00bb1251ab10 -size 889700 +oid sha256:327159735c38a6cc6c246aa19d813ed3989fa8ab841922c38e9e3d8f52c6d573 +size 805029 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png index 0fb060960..1007f7d36 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7768eed0683462de7d0e7cdd674096f278d563a77eafcfd4eedd3733bec57bc7 -size 1381989 +oid sha256:a51f6d91fa0b3aeb4b0cbe75e9177ea723d4a0ffce93bb479f83b229ff79fad7 +size 1247059 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png index 8e5da8d13..65b07663f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:25d2901f745fd07f5304a4a8f3c4bc5b36038b13d293bc23910b3bd89a2848d1 -size 1062340 +oid sha256:96b01a501c65c99951871a08456db9d988735c9c19291659c6f729c47394df37 +size 1052076 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png index a6c4bf587..7eb06a2ba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9734d783ecd8c364bc7a2d7682b601707c24e6881479f5fd5cb2a5fa92212f3b -size 2382886 +oid sha256:3143a402dbcbebf16b6f3b4bec965b543eec647be1449922a19d720ba28e50ce +size 2376508 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png index 707fefd15..572155edb 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/MainTabsSnapshotTests/mainTabs.WelcomeYear_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b60fd3e650ec00df974d191d0c0a392a6e0ecb8437b02466930029ef618d7316 -size 1339627 +oid sha256:98f460526a345ea45e732b41d58595d39f232f5dd9c3b4535f09eac7049bae6a +size 1302175 diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 8c56af8de..18e9422d1 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -117,7 +117,11 @@ /// `*YearReportModel()` fixture instead. @MainActor public static func loadedSession() -> WhereSession { - WhereSession(services: previewServices(), preferences: previewPreferences()) + WhereSession( + services: previewServices(), + preferences: previewPreferences(), + now: { referenceNow }, + ) } /// Current-device session whose permission must be promoted in Settings.app. @@ -128,6 +132,7 @@ locationSource: ScriptedLocationSource(authorizationStatus: .whenInUse), ), preferences: previewPreferences(), + now: { referenceNow }, ) } From 6f37527fd99507c44419c95e63fb12fe1b9ce49a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 16 Sep 2026 13:53:13 -0400 Subject: [PATCH 05/11] Keep welcome accessory from resetting iOS 26.0 tabs --- Where/WhereUI/README.md | 3 ++- Where/WhereUI/Sources/MainTabs.swift | 3 +++ ...gacyLocationWelcomeAccessoryModifier.swift | 21 +++++++++++++++++++ .../LocationWelcomeAccessoryModifier.swift | 4 ---- 4 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 01a0620a7..72a260be5 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -143,7 +143,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's Debug builds include **Reset Welcome Card** in Settings > Appearance beside the welcome-card toggle. The reset clears the saved region so the next foreground activation can show the card again. `MainTabs` requests one bounded fix on each active-scene entry. After one - second it shows a native tab-bar accessory; denied/restricted access and + second it shows a native tab-bar accessory on iOS 26.1 and later, or a + tab-content inset above the bar on iOS 26.0; denied/restricted access and disabled Precise Location remain actionable there, while transient or low-confidence failures disappear. Welcome cards must be enabled, and the device must confidently resolve a tracked region with recording active. diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 0c66cca2a..24ead9b01 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -80,16 +80,19 @@ struct MainTabs: View { ) { LocationsView(report: report) .reportingDeveloperTabBarInset() + .modifier(LegacyLocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) } Tab(String(localized: .tabYear), systemSymbol: .calendar, value: TabID.year) { YearView(report: report) .reportingDeveloperTabBarInset() + .modifier(LegacyLocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) } Tab(value: TabID.settings) { SettingsView(report: report, recordingWarning: recordingWarning) .reportingDeveloperTabBarInset() + .modifier(LegacyLocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) } label: { RecordingConfigurationWarningTabLabel( model: recordingWarning, diff --git a/Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift b/Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift new file mode 100644 index 000000000..15c53fa57 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// Keeps the tab navigation tree stable on iOS 26.0, which cannot dynamically +/// enable or disable a native tab-view bottom accessory. +struct LegacyLocationWelcomeAccessoryModifier: ViewModifier { + let accessory: LocationWelcomeModel.Accessory? + + func body(content: Content) -> some View { + if #available(iOS 26.1, *) { + content + } else { + content.safeAreaInset(edge: .bottom, spacing: 0) { + if let accessory { + LocationWelcomeStatusAccessory(accessory: accessory) + .background(.regularMaterial, in: Capsule()) + .padding(.horizontal) + } + } + } + } +} diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift index ae301e0b8..c1e64c6e6 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift @@ -11,10 +11,6 @@ struct LocationWelcomeAccessoryModifier: ViewModifier { LocationWelcomeStatusAccessory(accessory: accessory) } } - } else if let accessory { - content.tabViewBottomAccessory { - LocationWelcomeStatusAccessory(accessory: accessory) - } } else { content } From 8c6d81d38d3188a08196594ee3fb25cf3ce1f196 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 16 Sep 2026 14:00:04 -0400 Subject: [PATCH 06/11] Revalidate pending welcome on foreground activation --- .../Primary/LocationWelcomeModel.swift | 7 ++-- .../Tests/LocationWelcomeModelTests.swift | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift index ff463cf6e..eb4341766 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift @@ -76,7 +76,7 @@ public final class LocationWelcomeModel { /// Performs one independent foreground lookup. A later lookup supersedes /// any earlier result that reaches the model out of order. func resolve() async { - guard preferences.showsLocationWelcome, presentation == nil else { return } + guard preferences.showsLocationWelcome else { return } let (sequence, overflow) = resolutionSequence.addingReportingOverflow(1) precondition(!overflow, "Location welcome resolution sequence exhausted UInt64.") resolutionSequence = sequence @@ -122,10 +122,9 @@ public final class LocationWelcomeModel { guard !Task.isCancelled, preferences.showsLocationWelcome, - sequence == resolutionSequence, - presentation == nil + sequence == resolutionSequence else { - if sequence == resolutionSequence, presentation == nil { state = .idle } + if sequence == resolutionSequence { state = .idle } return } diff --git a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift index cc4e862ac..1348f8edc 100644 --- a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift +++ b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift @@ -42,6 +42,38 @@ struct LocationWelcomeModelTests { #expect(fixture.model.presentation == .init(region: .newYork, greeting: .returnVisit)) } + @Test func undismissedWelcomeIsRevalidatedOnEachActivation() 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, + now: { Self.now }, + ) + + let first = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + try await source.resolveRequest(at: 0, with: Self.sample(region: .california)) + await first.value + #expect(model.presentation == .init(region: .california, greeting: .first)) + + let second = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + #expect(model.presentation == nil) + try await source.resolveRequest(at: 0, with: Self.sample(region: .newYork)) + await second.value + #expect(model.presentation == .init(region: .newYork, greeting: .first)) + #expect(preferences.lastWelcomedRegion == nil) + + let third = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + await source.resolveRequest(at: 0, with: .unavailable(.timeout)) + await third.value + #expect(model.state == .idle) + } + @Test(arguments: [true, false]) func appearanceResetAllowsTheSameRegionToWelcomeAgainWhenEnabled(isEnabled: Bool) async throws { let fixture = try await fixture(region: .california) From d6ee7fb984c3d1107c8ed93d00acc52785f24970 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 16 Sep 2026 14:22:22 -0400 Subject: [PATCH 07/11] Isolate one-shot Core Location controls behind a driver --- Where/WhereCore/AGENTS.md | 4 +- Where/WhereCore/README.md | 4 +- .../Sources/Location/CoreLocationSource.swift | 124 ++++++++---------- .../Tests/CoreLocationSourceTests.swift | 29 +++- 4 files changed, 84 insertions(+), 77 deletions(-) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index a4fd262cb..d9b76c66e 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -126,7 +126,9 @@ internal shape. coupling their cancellation. Reject negative accuracy everywhere. Apply the 1 km, 60-second, and boundary-confidence gates only in `CurrentRegionResolver`; retain other valid passive samples. It backs - `LocationIngestor.captureTodayIfNeeded(now:)`. + `LocationIngestor.captureTodayIfNeeded(now:)`. Keep the one-shot system + controls behind `CurrentLocationRequestDriving`, with a conforming fake in + `CoreLocationSourceTests`. - **`DeviceRecordingController` owns this installation's local recording choice and physical GPS state.** Serialize mutations across awaits. Fail closed when the current identity is removed. Stamp every ingested GPS sample with the diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 2036c300e..334fb7189 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -126,7 +126,9 @@ one it belongs to rather than to a god-object: whose nonthrowing result distinguishes permission, precision, timeout, provider, and cancellation outcomes. Concurrent one-shot callers coalesce; cancellation removes only that caller. Cached callbacks must pass the - one-minute freshness gate before satisfying them. + one-minute freshness gate before satisfying them. The system-facing one-shot + controls use `CurrentLocationRequestDriving`; tests substitute a driver fake + while retaining the same request coordinator. - **`LocationIngestor`** — monitoring, the persist-with-retry queue, and authorization. After each committed sample it reconciles the badge/reminders and republishes the widget snapshot. Every automatic sample is stamped with diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index 4a94743d6..98fb9adc6 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -2,6 +2,47 @@ import CoreLocation import Foundation import RegionKit +/// System-facing controls for one bounded foreground fix. Tests replace the +/// Core Location implementation without changing the request coordinator. +@MainActor +@_spi(Testing) public protocol CurrentLocationRequestDriving: AnyObject { + var authorization: LocationAuthorizationStatus { get } + var hasPreciseLocation: Bool { get } + var timeout: Duration { get } + + func requestLocation() + func stopLocation() +} + +@MainActor +private final class SystemCurrentLocationRequestDriver: CurrentLocationRequestDriving { + private let manager: CLLocationManager + + init(manager: CLLocationManager) { + self.manager = manager + } + + var authorization: LocationAuthorizationStatus { + CoreLocationSource.map(manager.authorizationStatus) + } + + var hasPreciseLocation: Bool { + manager.accuracyAuthorization == .fullAccuracy + } + + var timeout: Duration { + .seconds(10) + } + + func requestLocation() { + manager.requestLocation() + } + + func stopLocation() { + manager.stopUpdatingLocation() + } +} + /// `LocationSource` driven by `CLLocationManager` using the two low-power /// signals appropriate for "what state am I in today" tracking: /// @@ -39,6 +80,7 @@ public final class CoreLocationSource: NSObject, LocationSource { } private let manager: CLLocationManager + private var currentLocationDriver: any CurrentLocationRequestDriving private nonisolated let sampleContinuation: AsyncStream.Continuation private nonisolated let authorizationBroadcaster = AuthorizationStatusBroadcaster() @@ -60,17 +102,6 @@ public final class CoreLocationSource: NSObject, LocationSource { private var currentLocationRequestStartedAt: Date? private var currentLocationTimeoutTask: Task? - #if DEBUG - private var testingAuthorizationStatus: LocationAuthorizationStatus? - private var testingHasPreciseLocation: Bool? - private var testingRequestLocation: (@MainActor @Sendable () -> Void)? - private var testingStopLocation: (@MainActor @Sendable () -> Void)? - private var testingCurrentLocationTimeout: Duration? - #endif - - /// How long to wait for a one-shot fix before reporting it unavailable. - /// Kept short so foreground callers are not held indefinitely. - private static let currentLocationTimeout: Duration = .seconds(10) private static let maximumCurrentLocationAge: TimeInterval = 60 override public init() { @@ -84,7 +115,9 @@ public final class CoreLocationSource: NSObject, LocationSource { sampleStream = AsyncStream { sampleCont = $0 } sampleContinuation = sampleCont - manager = CLLocationManager() + let manager = CLLocationManager() + self.manager = manager + currentLocationDriver = SystemCurrentLocationRequestDriver(manager: manager) super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyKilometer @@ -101,14 +134,14 @@ public final class CoreLocationSource: NSObject, LocationSource { } public func requestCurrentLocation() async -> CurrentLocationResult { - let authorization = oneShotAuthorizationStatus + let authorization = currentLocationDriver.authorization switch authorization { case .always, .whenInUse: break case .denied, .restricted, .notDetermined: return .unavailable(.authorizationUnavailable(authorization)) } - guard hasPreciseLocation else { + guard currentLocationDriver.hasPreciseLocation else { return .unavailable(.preciseLocationDisabled) } guard !Task.isCancelled else { return .unavailable(.cancellation) } @@ -136,8 +169,8 @@ public final class CoreLocationSource: NSObject, LocationSource { pendingLocationContinuations[id] = continuation guard pendingLocationContinuations.count == 1 else { return } currentLocationRequestStartedAt = Date() - requestUnderlyingLocation() - let timeout = currentLocationTimeout + currentLocationDriver.requestLocation() + let timeout = currentLocationDriver.timeout currentLocationTimeoutTask = Task { @MainActor [weak self] in do { try await Task.sleep(for: timeout) @@ -145,7 +178,7 @@ public final class CoreLocationSource: NSObject, LocationSource { return } self?.resolvePendingLocation(.unavailable(.timeout)) - self?.stopUnderlyingLocation() + self?.currentLocationDriver.stopLocation() } } @@ -156,7 +189,7 @@ public final class CoreLocationSource: NSObject, LocationSource { currentLocationRequestStartedAt = nil currentLocationTimeoutTask?.cancel() currentLocationTimeoutTask = nil - stopUnderlyingLocation() + currentLocationDriver.stopLocation() } /// Resume (and clear) every coalesced one-shot location waiter with the same @@ -184,61 +217,12 @@ public final class CoreLocationSource: NSObject, LocationSource { resolvePendingLocation(.success(freshest)) } - private var oneShotAuthorizationStatus: LocationAuthorizationStatus { - #if DEBUG - if let testingAuthorizationStatus { return testingAuthorizationStatus } - #endif - return Self.map(manager.authorizationStatus) - } - - private var hasPreciseLocation: Bool { - #if DEBUG - if let testingHasPreciseLocation { return testingHasPreciseLocation } - #endif - return manager.accuracyAuthorization == .fullAccuracy - } - - private var currentLocationTimeout: Duration { - #if DEBUG - if let testingCurrentLocationTimeout { return testingCurrentLocationTimeout } - #endif - return Self.currentLocationTimeout - } - - private func requestUnderlyingLocation() { - #if DEBUG - if let testingRequestLocation { - testingRequestLocation() - return - } - #endif - manager.requestLocation() - } - - private func stopUnderlyingLocation() { - #if DEBUG - if let testingStopLocation { - testingStopLocation() - return - } - #endif - manager.stopUpdatingLocation() - } - #if DEBUG - /// Replaces the system-facing one-shot controls for deterministic tests. + /// Replaces the system-facing driver for deterministic tests. @_spi(Testing) public func configureCurrentLocationForTesting( - authorization: LocationAuthorizationStatus, - hasPreciseLocation: Bool, - timeout: Duration, - request: @escaping @MainActor @Sendable () -> Void, - stop: @escaping @MainActor @Sendable () -> Void, + driver: any CurrentLocationRequestDriving, ) { - testingAuthorizationStatus = authorization - testingHasPreciseLocation = hasPreciseLocation - testingCurrentLocationTimeout = timeout - testingRequestLocation = request - testingStopLocation = stop + currentLocationDriver = driver } /// Delivers a test batch through the same freshness gate as Core Location. diff --git a/Where/WhereCore/Tests/CoreLocationSourceTests.swift b/Where/WhereCore/Tests/CoreLocationSourceTests.swift index 353aca355..751d31803 100644 --- a/Where/WhereCore/Tests/CoreLocationSourceTests.swift +++ b/Where/WhereCore/Tests/CoreLocationSourceTests.swift @@ -91,14 +91,12 @@ struct CoreLocationSourceTests { timeout: Duration = .seconds(10), ) -> (CoreLocationSource, LocationRequestProbe) { let source = CoreLocationSource() - let probe = LocationRequestProbe() - source.configureCurrentLocationForTesting( + let probe = LocationRequestProbe( authorization: authorization, hasPreciseLocation: hasPreciseLocation, timeout: timeout, - request: { probe.requestCount += 1 }, - stop: { probe.stopCount += 1 }, ) + source.configureCurrentLocationForTesting(driver: probe) return (source, probe) } @@ -122,7 +120,28 @@ struct CoreLocationSourceTests { } @MainActor -private final class LocationRequestProbe { +private final class LocationRequestProbe: CurrentLocationRequestDriving { + let authorization: LocationAuthorizationStatus + let hasPreciseLocation: Bool + let timeout: Duration var requestCount = 0 var stopCount = 0 + + init( + authorization: LocationAuthorizationStatus, + hasPreciseLocation: Bool, + timeout: Duration, + ) { + self.authorization = authorization + self.hasPreciseLocation = hasPreciseLocation + self.timeout = timeout + } + + func requestLocation() { + requestCount += 1 + } + + func stopLocation() { + stopCount += 1 + } } From 171c37bd0d2a822f05080228320e65dec9467f4a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 16 Sep 2026 14:24:10 -0400 Subject: [PATCH 08/11] Target hundred-meter accuracy for foreground fixes --- Where/WhereCore/README.md | 4 +++- Where/WhereCore/Sources/Location/CoreLocationSource.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 334fb7189..ecacdef54 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -126,7 +126,9 @@ one it belongs to rather than to a god-object: whose nonthrowing result distinguishes permission, precision, timeout, provider, and cancellation outcomes. Concurrent one-shot callers coalesce; cancellation removes only that caller. Cached callbacks must pass the - one-minute freshness gate before satisfying them. The system-facing one-shot + one-minute freshness gate before satisfying them. The one-shot request targets + 100 m accuracy to improve attribution near borders, while the live-region + decision still has a hard 1 km uncertainty cap. The system-facing one-shot controls use `CurrentLocationRequestDriving`; tests substitute a driver fake while retaining the same request coordinator. - **`LocationIngestor`** — monitoring, the persist-with-retry queue, and diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index 98fb9adc6..b78bc751c 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -120,7 +120,7 @@ public final class CoreLocationSource: NSObject, LocationSource { currentLocationDriver = SystemCurrentLocationRequestDriver(manager: manager) super.init() manager.delegate = self - manager.desiredAccuracy = kCLLocationAccuracyKilometer + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters } public func start() async { From 2146baf723cf955de53a8e432683d4f13115752d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 16 Sep 2026 14:29:17 -0400 Subject: [PATCH 09/11] Model foreground location requests as idle or pending --- Where/WhereCore/AGENTS.md | 3 +- Where/WhereCore/README.md | 3 +- .../Sources/Location/CoreLocationSource.swift | 92 ++++++++++++------- .../Tests/CoreLocationSourceTests.swift | 45 ++++++++- 4 files changed, 105 insertions(+), 38 deletions(-) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index d9b76c66e..7c45f440d 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -128,7 +128,8 @@ internal shape. `CurrentRegionResolver`; retain other valid passive samples. It backs `LocationIngestor.captureTodayIfNeeded(now:)`. Keep the one-shot system controls behind `CurrentLocationRequestDriving`, with a conforming fake in - `CoreLocationSourceTests`. + `CoreLocationSourceTests`. Keep coalesced waiters and their timeout in one + idle/pending request state; finish each waiter exactly once. - **`DeviceRecordingController` owns this installation's local recording choice and physical GPS state.** Serialize mutations across awaits. Fail closed when the current identity is removed. Stamp every ingested GPS sample with the diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index ecacdef54..8b7ca2a13 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -130,7 +130,8 @@ one it belongs to rather than to a god-object: 100 m accuracy to improve attribution near borders, while the live-region decision still has a hard 1 km uncertainty cap. The system-facing one-shot controls use `CurrentLocationRequestDriving`; tests substitute a driver fake - while retaining the same request coordinator. + while retaining the same request coordinator. Its idle/pending state owns the + coalesced waiters and timeout as one request. - **`LocationIngestor`** — monitoring, the persist-with-retry queue, and authorization. After each committed sample it reconciles the badge/reminders and republishes the widget snapshot. Every automatic sample is stamped with diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index b78bc751c..eec9ce87c 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -69,6 +69,17 @@ private final class SystemCurrentLocationRequestDriver: CurrentLocationRequestDr /// Core Location still delivers callbacks on the main run loop in practice. @MainActor public final class CoreLocationSource: NSObject, LocationSource { + private struct PendingCurrentLocationRequest { + let id: UUID + var waiters: [UUID: CheckedContinuation] + let timeoutTask: Task + } + + private enum CurrentLocationRequestState { + case idle + case pending(PendingCurrentLocationRequest) + } + public nonisolated let sampleStream: AsyncStream /// Each access returns an independent subscription (see @@ -92,15 +103,9 @@ public final class CoreLocationSource: NSObject, LocationSource { /// thus permanently strand — the first. private var pendingPermissionContinuations: [CheckedContinuation] = [] - /// Waiters for an in-flight `requestCurrentLocation()`. Overlapping callers - /// coalesce onto the next delivered fix (or the shared timeout / failure); - /// only the first triggers `requestLocation()`. Every waiter is resumed - /// together, so a second caller can't strand the first. - private var pendingLocationContinuations: [ - UUID: CheckedContinuation - ] = [:] - private var currentLocationRequestStartedAt: Date? - private var currentLocationTimeoutTask: Task? + /// Only a pending request owns waiters and a timeout. Concurrent callers + /// join that request; each cancellation removes just its own waiter. + private var currentLocationRequestState: CurrentLocationRequestState = .idle private static let maximumCurrentLocationAge: TimeInterval = 60 @@ -166,29 +171,49 @@ public final class CoreLocationSource: NSObject, LocationSource { continuation.resume(returning: .unavailable(.cancellation)) return } - pendingLocationContinuations[id] = continuation - guard pendingLocationContinuations.count == 1 else { return } - currentLocationRequestStartedAt = Date() - currentLocationDriver.requestLocation() - let timeout = currentLocationDriver.timeout - currentLocationTimeoutTask = Task { @MainActor [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - self?.resolvePendingLocation(.unavailable(.timeout)) - self?.currentLocationDriver.stopLocation() + switch currentLocationRequestState { + case .idle: + let requestID = id + let timeout = currentLocationDriver.timeout + let timeoutTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: timeout) + } catch { + return + } + self?.timeoutCurrentLocationRequest(id: requestID) + } + currentLocationRequestState = .pending(PendingCurrentLocationRequest( + id: requestID, + waiters: [id: continuation], + timeoutTask: timeoutTask, + )) + currentLocationDriver.requestLocation() + case var .pending(request): + request.waiters[id] = continuation + currentLocationRequestState = .pending(request) } } private func cancelLocationWaiter(id: UUID) { - guard let waiter = pendingLocationContinuations.removeValue(forKey: id) else { return } + guard case var .pending(request) = currentLocationRequestState, + let waiter = request.waiters.removeValue(forKey: id) + else { return } + if request.waiters.isEmpty { + currentLocationRequestState = .idle + request.timeoutTask.cancel() + currentLocationDriver.stopLocation() + } else { + currentLocationRequestState = .pending(request) + } waiter.resume(returning: .unavailable(.cancellation)) - guard pendingLocationContinuations.isEmpty else { return } - currentLocationRequestStartedAt = nil - currentLocationTimeoutTask?.cancel() - currentLocationTimeoutTask = nil + } + + private func timeoutCurrentLocationRequest(id: UUID) { + guard case let .pending(request) = currentLocationRequestState, + request.id == id + else { return } + resolvePendingLocation(.unavailable(.timeout)) currentLocationDriver.stopLocation() } @@ -196,19 +221,16 @@ public final class CoreLocationSource: NSObject, LocationSource { /// result. Cleared before resuming so a fix delivered after the timeout (or /// vice-versa) is a no-op rather than a double-resume. private func resolvePendingLocation(_ result: CurrentLocationResult) { - guard !pendingLocationContinuations.isEmpty else { return } - let waiters = pendingLocationContinuations.values - pendingLocationContinuations.removeAll() - currentLocationRequestStartedAt = nil - currentLocationTimeoutTask?.cancel() - currentLocationTimeoutTask = nil - for waiter in waiters { + guard case let .pending(request) = currentLocationRequestState else { return } + currentLocationRequestState = .idle + request.timeoutTask.cancel() + for waiter in request.waiters.values { waiter.resume(returning: result) } } private func resolvePendingLocationIfFresh(_ samples: [LocationSample]) { - guard currentLocationRequestStartedAt != nil else { return } + guard case .pending = currentLocationRequestState else { return } let now = Date() guard let freshest = samples .filter({ abs(now.timeIntervalSince($0.timestamp)) <= Self.maximumCurrentLocationAge }) diff --git a/Where/WhereCore/Tests/CoreLocationSourceTests.swift b/Where/WhereCore/Tests/CoreLocationSourceTests.swift index 751d31803..63e1ccb56 100644 --- a/Where/WhereCore/Tests/CoreLocationSourceTests.swift +++ b/Where/WhereCore/Tests/CoreLocationSourceTests.swift @@ -85,6 +85,47 @@ struct CoreLocationSourceTests { #expect(probe.requestCount == 1) } + @Test func cancellingTheLastWaiterStopsAndClearsTheRequest() async { + let (source, probe) = configuredSource() + let first = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 1 } + + first.cancel() + #expect(await first.value == .unavailable(.cancellation)) + #expect(probe.stopCount == 1) + + source.deliverCurrentLocationsForTesting([sample()]) + let second = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 2 } + let fresh = sample() + source.deliverCurrentLocationsForTesting([fresh]) + + #expect(await second.value == .success(fresh)) + } + + @Test func timedOutRequestCanStartAnotherRequest() async { + let (source, probe) = configuredSource(timeout: .milliseconds(1)) + #expect(await source.requestCurrentLocation() == .unavailable(.timeout)) + #expect(probe.stopCount == 1) + + probe.timeout = .seconds(10) + let retry = Task { await source.requestCurrentLocation() } + await waitUntil { probe.requestCount == 2 } + let fix = sample() + source.deliverCurrentLocationsForTesting([fix]) + + #expect(await retry.value == .success(fix)) + } + + @Test func synchronousDriverCallbackCompletesTheRequest() async { + let (source, probe) = configuredSource(timeout: .milliseconds(1)) + let fix = sample() + probe.onRequest = { source.deliverCurrentLocationsForTesting([fix]) } + + #expect(await source.requestCurrentLocation() == .success(fix)) + #expect(probe.stopCount == 0) + } + private func configuredSource( authorization: LocationAuthorizationStatus = .always, hasPreciseLocation: Bool = true, @@ -123,9 +164,10 @@ struct CoreLocationSourceTests { private final class LocationRequestProbe: CurrentLocationRequestDriving { let authorization: LocationAuthorizationStatus let hasPreciseLocation: Bool - let timeout: Duration + var timeout: Duration var requestCount = 0 var stopCount = 0 + var onRequest: (() -> Void)? init( authorization: LocationAuthorizationStatus, @@ -139,6 +181,7 @@ private final class LocationRequestProbe: CurrentLocationRequestDriving { func requestLocation() { requestCount += 1 + onRequest?() } func stopLocation() { From 133c12e87eb5f88de6068df362005c0e06925e17 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 16 Sep 2026 14:46:15 -0400 Subject: [PATCH 10/11] Pin Xcode renderer to build 27A266a CircleCI's Xcode 27.0 image and the local installation now use build 27A266a. The previous beta-build pin rejected the runner before the iOS build started. All 50 snapshot suites and 2,029 iOS unit tests pass on the new build without reference changes. --- .xcode-build-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.xcode-build-version b/.xcode-build-version index 8c834dd48..05dd9408a 100644 --- a/.xcode-build-version +++ b/.xcode-build-version @@ -1 +1 @@ -27A5252f +27A266a From 198d364bc3addfb02e1c3e906b53285791b7b38b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 17 Sep 2026 14:08:37 -0400 Subject: [PATCH 11/11] Remove iOS 26.0 welcome accessory fallback Keep the native dynamically enabled tab accessory on iOS 26.1 and later. iOS 26.0 continues resolving and presenting welcomes without an acquisition-status accessory. Validated: ./test WhereUITests (501 passed); ./test --snapshots --only WhereUISnapshotTests/MainTabsSnapshotTests/mainTabs() --review (passed); ./swiftformat --lint; git diff --check. --- Where/WhereUI/README.md | 13 ++++++------ Where/WhereUI/Sources/MainTabs.swift | 3 --- ...gacyLocationWelcomeAccessoryModifier.swift | 21 ------------------- .../LocationWelcomeAccessoryModifier.swift | 2 +- 4 files changed, 8 insertions(+), 31 deletions(-) delete mode 100644 Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 72a260be5..90ef8a0e8 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -142,12 +142,13 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's The scrim fades independently. Reduce Motion uses a short fade for both layers. Debug builds include **Reset Welcome Card** in Settings > Appearance beside the welcome-card toggle. The reset clears the saved region so the next foreground activation can show the card again. - `MainTabs` requests one bounded fix on each active-scene entry. After one - second it shows a native tab-bar accessory on iOS 26.1 and later, or a - tab-content inset above the bar on iOS 26.0; denied/restricted access and - disabled Precise Location remain actionable there, while transient or - low-confidence failures disappear. Welcome cards must be enabled, and the - device must confidently resolve a tracked region with recording active. + `MainTabs` requests one bounded fix on each active-scene entry. On iOS 26.1 + and later, it shows a native tab-bar accessory after one second; denied or + restricted access and disabled Precise Location remain actionable there, + while transient or low-confidence failures disappear. iOS 26.0 still runs + the lookup and presents the welcome card, but does not show the accessory. + Welcome cards must be enabled, and the device must confidently resolve a + tracked region with recording active. - **`OnboardingView` / `OnboardingFlowModel`** — the rendered first-run flow and its view-scoped observable coordinator, registered for the launch's diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 24ead9b01..0c66cca2a 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -80,19 +80,16 @@ struct MainTabs: View { ) { LocationsView(report: report) .reportingDeveloperTabBarInset() - .modifier(LegacyLocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) } Tab(String(localized: .tabYear), systemSymbol: .calendar, value: TabID.year) { YearView(report: report) .reportingDeveloperTabBarInset() - .modifier(LegacyLocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) } Tab(value: TabID.settings) { SettingsView(report: report, recordingWarning: recordingWarning) .reportingDeveloperTabBarInset() - .modifier(LegacyLocationWelcomeAccessoryModifier(accessory: welcomeAccessory)) } label: { RecordingConfigurationWarningTabLabel( model: recordingWarning, diff --git a/Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift b/Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift deleted file mode 100644 index 15c53fa57..000000000 --- a/Where/WhereUI/Sources/Primary/LegacyLocationWelcomeAccessoryModifier.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -/// Keeps the tab navigation tree stable on iOS 26.0, which cannot dynamically -/// enable or disable a native tab-view bottom accessory. -struct LegacyLocationWelcomeAccessoryModifier: ViewModifier { - let accessory: LocationWelcomeModel.Accessory? - - func body(content: Content) -> some View { - if #available(iOS 26.1, *) { - content - } else { - content.safeAreaInset(edge: .bottom, spacing: 0) { - if let accessory { - LocationWelcomeStatusAccessory(accessory: accessory) - .background(.regularMaterial, in: Capsule()) - .padding(.horizontal) - } - } - } - } -} diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift index c1e64c6e6..90400e0df 100644 --- a/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeAccessoryModifier.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Adds the system tab accessory only while live-region status is visible. +/// Adds the system tab accessory only while live-region status is visible on iOS 26.1 and later. struct LocationWelcomeAccessoryModifier: ViewModifier { let accessory: LocationWelcomeModel.Accessory?