Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ one it belongs to rather than to a god-object:
- **`PlannedStayLocationVerifier`** — gets a current location and compares it with the selected
region. The configured drift threshold expands the accepted area outside the region boundary.
A missing location or missing geometry returns an unavailable result.
- **`CurrentRegionResolver`** — returns the current tracked region only while automatic recording
is authorized. It returns `nil` when no live fix exists or the fix is outside tracked regions.

- **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a
given `WhereServices`: a plausible current year of living in New York with
Expand Down Expand Up @@ -173,9 +175,9 @@ one it belongs to rather than to a god-object:
`InstallationRecordingContextStoring` keeps the persistence adapter outside
the domain value.
- **`WherePreferences`** — persisted user intent (onboarding,
reminder / summary schedules, presentation theme, and Locations-card GPS-dot and
estimated-time/planning visibility) plus the
year-keyed Location-card counts and Codable recording-warning generation used for presentation
reminder / summary schedules, presentation theme, and Locations-card GPS-dot,
live-region welcome, and estimated-time/planning visibility) plus the
year-keyed Location-card counts, last welcomed region, and recording-warning generation used for presentation
continuity, behind a `KeyValueStore`. It also owns the vendor-neutral
`DiagnosticReportingConfiguration`: crash reports default On, replay Off,
and remote logs Off in Release / Warning in Debug. `WherePreferences` encodes
Expand Down
21 changes: 21 additions & 0 deletions Where/WhereCore/Sources/Location/CurrentRegionResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import RegionKit

/// Resolves the device's current tracked region while automatic recording is authorized.
public struct CurrentRegionResolver: Sendable {
private let ingestor: LocationIngestor
private let attributor: any RegionAttributing

init(ingestor: LocationIngestor, attributor: any RegionAttributing) {
self.ingestor = ingestor
self.attributor = attributor
}

/// Returns a tracked region from a best-effort live fix, or `nil` when no welcome is valid.
public func resolve() async -> Region? {
guard await ingestor.isRecordingAuthorized else { return nil }
guard let sample = await ingestor.currentLocation(), !Task.isCancelled else { return nil }
guard await ingestor.isRecordingAuthorized else { return nil }
let region = attributor.region(at: sample.coordinate)
return region == .other ? nil : region
}
}
5 changes: 5 additions & 0 deletions Where/WhereCore/Sources/Location/LocationIngestor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,11 @@ public actor LocationIngestor {
isMonitoring
}

/// Whether the current device policy authorizes automatic location samples.
public var isRecordingAuthorized: Bool {
if case .open = recordingAuthority { true } else { false }
}

/// Number of samples currently waiting to be re-persisted. Exposed for
/// tests; production callers should treat this as opaque.
public var retryQueueDepth: Int {
Expand Down
25 changes: 25 additions & 0 deletions Where/WhereCore/Sources/Preferences/WherePreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ public final class WherePreferences {
set { store.set(newValue, forKey: Keys.showsRecordedLocationDots.rawValue) }
}

/// Whether Locations may present the live-region welcome card. Defaults to
/// `true` so the acknowledgement remains available until explicitly hidden.
public var showsLocationWelcome: Bool {
get { store.object(forKey: Keys.showsLocationWelcome.rawValue) as? Bool ?? true }
set { store.set(newValue, forKey: Keys.showsLocationWelcome.rawValue) }
}

/// The device-local presentation theme. Missing and unrecognized values
/// resolve to Standard so upgrades preserve the app's familiar appearance.
public var theme: WhereTheme {
Expand Down Expand Up @@ -222,6 +229,22 @@ public final class WherePreferences {
store.set(snapshots, forKey: Keys.lastSeenLocationDayCounts.rawValue)
}

/// The last live region whose Locations welcome the user dismissed.
public var lastWelcomedRegion: Region? {
get {
guard let rawValue = store.object(forKey: Keys.lastWelcomedRegion.rawValue) as? String
else { return nil }
return Region(rawValue: rawValue)
}
set {
if let newValue {
store.set(newValue.rawValue, forKey: Keys.lastWelcomedRegion.rawValue)
} else {
store.removeObject(forKey: Keys.lastWelcomedRegion.rawValue)
}
}
}

/// Clear every persisted preference so the next launch behaves like a fresh
/// install: onboarding shows again, presentation and notification settings
/// revert to defaults, and UI continuity snapshots are forgotten.
Expand All @@ -239,6 +262,7 @@ public final class WherePreferences {
private enum Keys: String, CaseIterable {
case hasOnboarded = "where.hasOnboarded"
case showsRecordedLocationDots = "where.showsRecordedLocationDots"
case showsLocationWelcome = "where.showsLocationWelcome"
case theme = "where.theme"
case showsLocationForecastsOnLocationsTab = "where.showsLocationForecastsOnLocationsTab"
case remindersEnabled = "where.remindersEnabled"
Expand All @@ -253,6 +277,7 @@ public final class WherePreferences {
"where.recordingConfigurationWarningRegistration"
case driftThresholdMeters = "where.driftThresholdMeters"
case lastSeenLocationDayCounts = "where.lastSeenLocationDayCounts"
case lastWelcomedRegion = "where.lastWelcomedRegion"
}

private static var isDebugBuild: Bool {
Expand Down
4 changes: 4 additions & 0 deletions Where/WhereCore/Sources/WhereServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public struct WhereServices: Sendable {
public let plannedStays: PlannedStayCoordinator
/// Best-effort current-location verification for the planned-stay editor.
public let plannedStayLocation: PlannedStayLocationVerifier
/// Best-effort live tracked-region lookup for presentation acknowledgements.
public let currentRegion: CurrentRegionResolver
/// Data-quality issue detection for the Resolve tab.
public let resolution: DataIssueScanner
/// The persistence boundary, retained so `dataChangeUpdates()` can hand out
Expand Down Expand Up @@ -275,6 +277,7 @@ public struct WhereServices: Sendable {
ingestor: ingestor,
attributor: attributor,
)
let currentRegion = CurrentRegionResolver(ingestor: ingestor, attributor: attributor)
self.reports = reports
self.evidence = evidence
self.reminders = reminders
Expand All @@ -287,6 +290,7 @@ public struct WhereServices: Sendable {
self.backup = backup
self.plannedStays = plannedStays
self.plannedStayLocation = plannedStayLocation
self.currentRegion = currentRegion
self.resolution = resolution
self.store = store
self.attributor = attributor
Expand Down
114 changes: 114 additions & 0 deletions Where/WhereCore/Tests/CurrentRegionResolverTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation
import RegionKit
import Testing
@_spi(Testing) @testable import WhereCore

struct CurrentRegionResolverTests {
@Test func resolvesTrackedRegionWhileRecordingIsAuthorized() async throws {
let (services, source) = try makeServices()
source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194))
try await services.ingestor.authorizeRecording()

#expect(await services.currentRegion.resolve() == .california)
}

@Test func inactiveRecordingDoesNotRequestAWelcomeRegion() async throws {
let (services, source) = try makeServices()
source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194))

#expect(await services.currentRegion.resolve() == nil)
}

@Test func missingFixDoesNotResolveAWelcomeRegion() async throws {
let (services, _) = try makeServices()
try await services.ingestor.authorizeRecording()

#expect(await services.currentRegion.resolve() == nil)
}

@Test func locationOutsideTrackedRegionsDoesNotResolveAWelcomeRegion() async throws {
let (services, source) = try makeServices(
attributor: RegionAttributor(for: [.california]),
)
source.setNextRequestedLocation(sample(latitude: 40.7128, longitude: -74.0060))
try await services.ingestor.authorizeRecording()

#expect(await services.currentRegion.resolve() == nil)
}

@Test func authorizationRevokedDuringFixRequestDoesNotResolveAWelcomeRegion() async throws {
let source = GatedWelcomeLocationSource()
let services = try WhereServices(
store: SwiftDataStore.inMemory(),
locationSource: source,
)
try await services.ingestor.authorizeRecording()
let resolution = Task { await services.currentRegion.resolve() }
await source.waitUntilRequested()

await services.ingestor.revokeRecordingAuthorization()
await source.resolve(with: sample(latitude: 37.7749, longitude: -122.4194))

#expect(await resolution.value == nil)
}

private func makeServices(
attributor: any RegionAttributing = RegionAttributor.shared,
) throws -> (WhereServices, ScriptedLocationSource) {
let source = ScriptedLocationSource()
return try (
WhereServices(
store: SwiftDataStore.inMemory(),
locationSource: source,
attributor: attributor,
),
source,
)
}

private func sample(latitude: Double, longitude: Double) -> LocationSample {
LocationSample(
timestamp: Date(timeIntervalSinceReferenceDate: 0),
coordinate: Coordinate(latitude: latitude, longitude: longitude),
horizontalAccuracy: 5,
source: .gpsSignificantChange,
)
}
}

private actor GatedWelcomeLocationSource: LocationSource {
nonisolated let sampleStream = AsyncStream<LocationSample> { $0.finish() }
nonisolated let authorizationUpdates = AsyncStream<LocationAuthorizationStatus> { $0.finish() }

private var requestContinuation: CheckedContinuation<LocationSample?, Never>?
private var requestWaiters: [CheckedContinuation<Void, Never>] = []
private var didRequest = false

func start() async {}
func stop() async {}

func requestCurrentLocation() async -> LocationSample? {
didRequest = true
for waiter in requestWaiters {
waiter.resume()
}
requestWaiters.removeAll()
return await withCheckedContinuation { requestContinuation = $0 }
}

func currentAuthorization() async -> LocationAuthorizationStatus {
.always
}

func requestPermission() async throws {}

func waitUntilRequested() async {
guard didRequest == false else { return }
await withCheckedContinuation { requestWaiters.append($0) }
}

func resolve(with sample: LocationSample?) {
requestContinuation?.resume(returning: sample)
requestContinuation = nil
}
}
24 changes: 24 additions & 0 deletions Where/WhereCore/Tests/WherePreferencesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct WherePreferencesTests {

#expect(preferences.hasOnboarded == false)
#expect(preferences.showsRecordedLocationDots)
#expect(preferences.showsLocationWelcome)
#expect(preferences.theme == .standard)
#expect(preferences.showsEstimatedTimeAndPlanning)
#expect(preferences.remindersEnabled)
Expand All @@ -26,6 +27,7 @@ struct WherePreferencesTests {
)
#expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue)
#expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil)
#expect(preferences.lastWelcomedRegion == nil)
}

@Test(arguments: [
Expand Down Expand Up @@ -127,6 +129,24 @@ struct WherePreferencesTests {
#expect(preferences.lastSeenLocationDayCounts(in: 2026) == counts2026)
}

@Test func lastWelcomedRegionRoundTripsAndClears() {
let preferences = preferences()

preferences.lastWelcomedRegion = .newYork
#expect(preferences.lastWelcomedRegion == .newYork)

preferences.lastWelcomedRegion = nil
#expect(preferences.lastWelcomedRegion == nil)
}

@Test func locationWelcomeVisibilityRoundTrips() {
let preferences = preferences()

preferences.showsLocationWelcome = false

#expect(preferences.showsLocationWelcome == false)
}

@Test func estimatedTimeUsesTheLegacyLocationsVisibilityKey() {
let store = InMemoryKeyValueStore()
store.set(false, forKey: "where.showsLocationForecastsOnLocationsTab")
Expand Down Expand Up @@ -180,6 +200,7 @@ struct WherePreferencesTests {
let preferences = preferences()
preferences.hasOnboarded = true
preferences.showsRecordedLocationDots = false
preferences.showsLocationWelcome = false
preferences.theme = .alternate
preferences.showsEstimatedTimeAndPlanning = false
preferences.remindersEnabled = false
Expand All @@ -193,6 +214,7 @@ struct WherePreferencesTests {
preferences.recordingConfigurationWarningRegistration = recordingWarning
preferences.driftThresholdMeters = 25000
preferences.setLastSeenLocationDayCounts([.california: 100], in: 2026)
preferences.lastWelcomedRegion = .california
preferences.diagnosticReportingConfiguration = DiagnosticReportingConfiguration(
sharesCrashReports: false,
sharesSessionReplays: true,
Expand All @@ -206,6 +228,7 @@ struct WherePreferencesTests {

#expect(preferences.hasOnboarded == false)
#expect(preferences.showsRecordedLocationDots)
#expect(preferences.showsLocationWelcome)
#expect(preferences.theme == .standard)
#expect(preferences.showsEstimatedTimeAndPlanning)
#expect(preferences.remindersEnabled)
Expand All @@ -219,6 +242,7 @@ struct WherePreferencesTests {
)
#expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue)
#expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil)
#expect(preferences.lastWelcomedRegion == nil)
#expect(
preferences.diagnosticReportingConfiguration
== DiagnosticReportingConfiguration.currentBuildDefaults,
Expand Down
7 changes: 6 additions & 1 deletion Where/WhereUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
demo, and completion orchestration) and **`OnboardingImportRecoveryModel`** (the sidecar/store
recovery handshake after an interrupted onboarding import), and
**`LocationCardsPresentationModel`** (the last primary-card counts and order
the user saw). The Location model holds saved values until the card surface
the user saw), and **`LocationWelcomeModel`** (the preference-gated current-region welcome and
its persisted acknowledgement). The Location model holds saved values until the card surface
is visible and unobscured, holds them there for another half second, then
advances every changed number and any live two-card reversal in one animated
beat, adding one light haptic. Decreases, first visits, hidden updates, and
Expand All @@ -130,6 +131,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's

### Reusable views & styling

- **`RegionWelcomeCard`** — a centered Locations overlay that combines a region's emoji,
icon, outline, Liquid Glass card treatment, and passport ink. It uses a spring transition
and an opacity-only transition when Reduce Motion is enabled.

- **`OnboardingView` / `OnboardingFlowModel`** — the rendered first-run flow and its view-scoped
observable coordinator, registered for the launch's
`OnboardingGate` and handed its `LifecycleGateHandle`. The gate roots the
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading