diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 48d3eb53c..76b8e2fbc 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -70,6 +70,8 @@ one it belongs to rather than to a god-object: - **`PlannedStayLocationVerifier`** — gets a current location and compares it with the selected region. The configured drift threshold expands the accepted area outside the region boundary. A missing location or missing geometry returns an unavailable result. +- **`CurrentRegionResolver`** — returns the current tracked region only while automatic recording + is authorized. It returns `nil` when no live fix exists or the fix is outside tracked regions. - **`DemoDataBuilder`** — writes the dataset the app's demo mode runs on into a given `WhereServices`: a plausible current year of living in New York with @@ -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 diff --git a/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift b/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift new file mode 100644 index 000000000..1be5aaf48 --- /dev/null +++ b/Where/WhereCore/Sources/Location/CurrentRegionResolver.swift @@ -0,0 +1,21 @@ +import RegionKit + +/// Resolves the device's current tracked region while automatic recording is authorized. +public struct CurrentRegionResolver: Sendable { + private let ingestor: LocationIngestor + private let attributor: any RegionAttributing + + init(ingestor: LocationIngestor, attributor: any RegionAttributing) { + self.ingestor = ingestor + self.attributor = attributor + } + + /// Returns a tracked region from a best-effort live fix, or `nil` when no welcome is valid. + public func resolve() async -> Region? { + guard await ingestor.isRecordingAuthorized else { return nil } + guard let sample = await ingestor.currentLocation(), !Task.isCancelled else { return nil } + guard await ingestor.isRecordingAuthorized else { return nil } + let region = attributor.region(at: sample.coordinate) + return region == .other ? nil : region + } +} diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 9556344b0..7c8adba94 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -324,6 +324,11 @@ public actor LocationIngestor { isMonitoring } + /// Whether the current device policy authorizes automatic location samples. + public var isRecordingAuthorized: Bool { + if case .open = recordingAuthority { true } else { false } + } + /// Number of samples currently waiting to be re-persisted. Exposed for /// tests; production callers should treat this as opaque. public var retryQueueDepth: Int { diff --git a/Where/WhereCore/Sources/Preferences/WherePreferences.swift b/Where/WhereCore/Sources/Preferences/WherePreferences.swift index c25bec45c..fe822f32c 100644 --- a/Where/WhereCore/Sources/Preferences/WherePreferences.swift +++ b/Where/WhereCore/Sources/Preferences/WherePreferences.swift @@ -49,6 +49,13 @@ public final class WherePreferences { set { store.set(newValue, forKey: Keys.showsRecordedLocationDots.rawValue) } } + /// Whether Locations may present the live-region welcome card. Defaults to + /// `true` so the acknowledgement remains available until explicitly hidden. + public var showsLocationWelcome: Bool { + get { store.object(forKey: Keys.showsLocationWelcome.rawValue) as? Bool ?? true } + set { store.set(newValue, forKey: Keys.showsLocationWelcome.rawValue) } + } + /// The device-local presentation theme. Missing and unrecognized values /// resolve to Standard so upgrades preserve the app's familiar appearance. public var theme: WhereTheme { @@ -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. @@ -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" @@ -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 { diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index abe7cb7f1..9f1bf9c35 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -63,6 +63,8 @@ public struct WhereServices: Sendable { public let plannedStays: PlannedStayCoordinator /// Best-effort current-location verification for the planned-stay editor. public let plannedStayLocation: PlannedStayLocationVerifier + /// Best-effort live tracked-region lookup for presentation acknowledgements. + public let currentRegion: CurrentRegionResolver /// Data-quality issue detection for the Resolve tab. public let resolution: DataIssueScanner /// The persistence boundary, retained so `dataChangeUpdates()` can hand out @@ -275,6 +277,7 @@ public struct WhereServices: Sendable { ingestor: ingestor, attributor: attributor, ) + let currentRegion = CurrentRegionResolver(ingestor: ingestor, attributor: attributor) self.reports = reports self.evidence = evidence self.reminders = reminders @@ -287,6 +290,7 @@ public struct WhereServices: Sendable { self.backup = backup self.plannedStays = plannedStays self.plannedStayLocation = plannedStayLocation + self.currentRegion = currentRegion self.resolution = resolution self.store = store self.attributor = attributor diff --git a/Where/WhereCore/Tests/CurrentRegionResolverTests.swift b/Where/WhereCore/Tests/CurrentRegionResolverTests.swift new file mode 100644 index 000000000..414071315 --- /dev/null +++ b/Where/WhereCore/Tests/CurrentRegionResolverTests.swift @@ -0,0 +1,114 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) @testable import WhereCore + +struct CurrentRegionResolverTests { + @Test func resolvesTrackedRegionWhileRecordingIsAuthorized() async throws { + let (services, source) = try makeServices() + source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194)) + try await services.ingestor.authorizeRecording() + + #expect(await services.currentRegion.resolve() == .california) + } + + @Test func inactiveRecordingDoesNotRequestAWelcomeRegion() async throws { + let (services, source) = try makeServices() + source.setNextRequestedLocation(sample(latitude: 37.7749, longitude: -122.4194)) + + #expect(await services.currentRegion.resolve() == nil) + } + + @Test func missingFixDoesNotResolveAWelcomeRegion() async throws { + let (services, _) = try makeServices() + try await services.ingestor.authorizeRecording() + + #expect(await services.currentRegion.resolve() == nil) + } + + @Test func locationOutsideTrackedRegionsDoesNotResolveAWelcomeRegion() async throws { + let (services, source) = try makeServices( + attributor: RegionAttributor(for: [.california]), + ) + source.setNextRequestedLocation(sample(latitude: 40.7128, longitude: -74.0060)) + try await services.ingestor.authorizeRecording() + + #expect(await services.currentRegion.resolve() == nil) + } + + @Test func authorizationRevokedDuringFixRequestDoesNotResolveAWelcomeRegion() async throws { + let source = GatedWelcomeLocationSource() + let services = try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + ) + try await services.ingestor.authorizeRecording() + let resolution = Task { await services.currentRegion.resolve() } + await source.waitUntilRequested() + + await services.ingestor.revokeRecordingAuthorization() + await source.resolve(with: sample(latitude: 37.7749, longitude: -122.4194)) + + #expect(await resolution.value == nil) + } + + private func makeServices( + attributor: any RegionAttributing = RegionAttributor.shared, + ) throws -> (WhereServices, ScriptedLocationSource) { + let source = ScriptedLocationSource() + return try ( + WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: source, + attributor: attributor, + ), + source, + ) + } + + private func sample(latitude: Double, longitude: Double) -> LocationSample { + LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 0), + coordinate: Coordinate(latitude: latitude, longitude: longitude), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + } +} + +private actor GatedWelcomeLocationSource: LocationSource { + nonisolated let sampleStream = AsyncStream { $0.finish() } + nonisolated let authorizationUpdates = AsyncStream { $0.finish() } + + private var requestContinuation: CheckedContinuation? + private var requestWaiters: [CheckedContinuation] = [] + private var didRequest = false + + func start() async {} + func stop() async {} + + func requestCurrentLocation() async -> LocationSample? { + didRequest = true + for waiter in requestWaiters { + waiter.resume() + } + requestWaiters.removeAll() + return await withCheckedContinuation { requestContinuation = $0 } + } + + func currentAuthorization() async -> LocationAuthorizationStatus { + .always + } + + func requestPermission() async throws {} + + func waitUntilRequested() async { + guard didRequest == false else { return } + await withCheckedContinuation { requestWaiters.append($0) } + } + + func resolve(with sample: LocationSample?) { + requestContinuation?.resume(returning: sample) + requestContinuation = nil + } +} diff --git a/Where/WhereCore/Tests/WherePreferencesTests.swift b/Where/WhereCore/Tests/WherePreferencesTests.swift index f3dad643d..1d5555789 100644 --- a/Where/WhereCore/Tests/WherePreferencesTests.swift +++ b/Where/WhereCore/Tests/WherePreferencesTests.swift @@ -13,6 +13,7 @@ struct WherePreferencesTests { #expect(preferences.hasOnboarded == false) #expect(preferences.showsRecordedLocationDots) + #expect(preferences.showsLocationWelcome) #expect(preferences.theme == .standard) #expect(preferences.showsEstimatedTimeAndPlanning) #expect(preferences.remindersEnabled) @@ -26,6 +27,7 @@ struct WherePreferencesTests { ) #expect(preferences.driftThresholdMeters == DriftThreshold.default.rawValue) #expect(preferences.lastSeenLocationDayCounts(in: 2026) == nil) + #expect(preferences.lastWelcomedRegion == nil) } @Test(arguments: [ @@ -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") @@ -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 @@ -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, @@ -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) @@ -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, diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 5beb17cd6..2da87ad72 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -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 @@ -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 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png index 4eaa1e9f7..15ae7d909 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:05aeb9b9b5989fad5e732d76700a180ea3e355508783e5353c86aa427491efc7 -size 1266390 +oid sha256:d8c404376e9f4021d86df9a49e287878ea43920e9145a6fcd0d5c070cf2b6953 +size 1299833 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png index 124037f60..87a8f7b0c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f59a33fdafaca34e09a4b01efbe1107b42daf16b255524b2b97b29b35ed83b2 -size 1226794 +oid sha256:7f7a172148bd9ee8d74e220719127661c8dd20722a5ac39d3b814ab245644c4b +size 1306081 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png index a76f6fd49..19bd1505d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bd46157b83a5c792bad30423b1fbd51a99621a3aae80e8a78b4231961be4fd5 -size 3409730 +oid sha256:13196d4dd04ea3a7b40a0b391b2757311580ed422853ffcd5e5fd01296145722 +size 3539335 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png index 4efe42f68..0e6443421 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:41cec6bc4760f3908133e092bc13badd0ebac992e2ca03058f01163397ac0661 -size 1329174 +oid sha256:b2811b25e511d33474cfbb945e5f324d4c717087c0c95b0f66313da66ef840b4 +size 1363243 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png index 5f567556f..cf539f78b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:48a8798cfdbbe072b2fffdd36593a30ccc437fad08e760056f544a96cfc3c42a -size 1507656 +oid sha256:d3a72192d2c152ce44c254f40af253bf38b1b659ca88fea9c293351652e9abf7 +size 1541919 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png index d27b9f232..5dd12dadf 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1fcbf2d3652b3ca891e6230d9b87bd4bd55e378ea8bfa602cc36b562957c1f13 -size 845476 +oid sha256:67a17a855e40ee123106eb324a032517ebc547450d86b2628c7c960cad2d0611 +size 837739 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png index 5b2bf0d65..e01c06e8b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd082a88961a7e9118fcffc9304446bd8196ac2c9aa04ef381aa7c301973c806 -size 953698 +oid sha256:0ab865fb220c599429ba1ea42badc20ec8f4cf47d03207af52c50e94caa9c7d5 +size 1024509 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png index 869fa1f23..a00e187a6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ff9a054f57286c0be114c3e7f18873bc62d7e0557a1bd8c58b6e7e2d985a95f -size 2334670 +oid sha256:7c09cc7eb5dbabc1120554df61ec356d233f0c2a531a0112799a27ac167916ef +size 2435957 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png index 5ccd21bc6..9a856d12a 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:023793fc7ca23b9dd4157ed08c867cf928520d971dd6230f28e8b736384e9886 -size 897068 +oid sha256:f3d5091d586f5e1511afa22bfec29dcf3553458297c85f662286d09cadf05d26 +size 883379 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png index 6e353863a..383c358e6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/AppearanceSettingsViewSnapshotTests/appearance.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9340086e57e79f3da8ea4d20ba8997d7ce0736af9c54b6d99d87f1587b382fc9 -size 940008 +oid sha256:dd8c66be2db4a82f647556484dfb7c90a865addf7143c443c1870495805851c1 +size 927430 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png new file mode 100644 index 000000000..c4c5edf45 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256: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 new file mode 100644 index 000000000..732b1e7eb --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeBack_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256: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 new file mode 100644 index 000000000..dacfdbd69 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256: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 new file mode 100644 index 000000000..1cac1600e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256: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 new file mode 100644 index 000000000..5374758e9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.WelcomeFirst_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ed3d5c73b6ac4950417e6f6d33dd789334a7efbb46fa995db31b2b7502bdc7c +size 1885876 diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 08e6a20d5..17eb77a26 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -150,6 +150,10 @@ public final class YearReportModel { /// model publishes this value to both the Appearance toggle and Locations. private var showsRecordedLocationDotsStorage: Bool + /// Observed mirror of the live-region welcome visibility preference. The + /// Appearance toggle and mounted Locations root share this scene state. + private var showsLocationWelcomeStorage: Bool + /// Observed mirror of the estimated-time and planning visibility preference. /// `WherePreferences` is intentionally not observable, so the async intent /// below updates every mounted forecast/planning surface immediately. @@ -181,6 +185,17 @@ public final class YearReportModel { } } + /// Whether Locations may resolve and present its live-region welcome. + /// Writes persist synchronously and hide a mounted welcome immediately. + var showsLocationWelcome: Bool { + get { showsLocationWelcomeStorage } + set { + guard newValue != showsLocationWelcomeStorage else { return } + showsLocationWelcomeStorage = newValue + preferences.showsLocationWelcome = newValue + } + } + /// GPS border-drift detection threshold (device setting). The setter persists /// it, forces a badge recount, and — through the observed mirror — re-keys /// `dataIssueScanInputs` so the Resolve list re-scans immediately, not just on @@ -272,6 +287,7 @@ public final class YearReportModel { driftThresholdStorage = DriftThreshold(rawValue: preferences.driftThresholdMeters) ?? .default showsRecordedLocationDotsStorage = preferences.showsRecordedLocationDots + showsLocationWelcomeStorage = preferences.showsLocationWelcome showsEstimatedTimeAndPlanning = preferences.showsEstimatedTimeAndPlanning var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .current diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift new file mode 100644 index 000000000..1f955d081 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeModel.swift @@ -0,0 +1,67 @@ +import Observation +import RegionKit +import WhereCore + +/// Presentation state for the live-region welcome on the Locations tab. +@MainActor +@Observable +public final class LocationWelcomeModel { + public struct Presentation: Equatable { + public enum Greeting: Equatable { + case first + case returnVisit + } + + public let region: Region + public let greeting: Greeting + } + + public private(set) var presentation: Presentation? + + private let resolver: CurrentRegionResolver + private let preferences: WherePreferences + private var resolutionSequence: UInt64 = 0 + + init(services: WhereServices, preferences: WherePreferences) { + resolver = services.currentRegion + self.preferences = preferences + } + + /// Resolves a fresh welcome while the Locations root is visible. + func resolve() async { + guard preferences.showsLocationWelcome, presentation == nil else { return } + let (sequence, overflow) = resolutionSequence.addingReportingOverflow(1) + precondition(!overflow, "Location welcome resolution sequence exhausted UInt64.") + resolutionSequence = sequence + + guard let region = await resolver.resolve() else { return } + guard + !Task.isCancelled, + 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, + ) + } + + func dismiss() { + guard let presentation else { return } + preferences.lastWelcomedRegion = presentation.region + self.presentation = nil + } + + #if DEBUG + /// Seeds a deterministic state for previews and image snapshots. + @_spi(Testing) public func presentForTesting( + region: Region, + greeting: Presentation.Greeting, + ) { + presentation = Presentation(region: region, greeting: greeting) + } + #endif +} diff --git a/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift new file mode 100644 index 000000000..7008e9095 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift @@ -0,0 +1,58 @@ +import RegionKit +import SwiftUI +import UIKit + +/// The modal scrim and adaptive placement for a Locations welcome card. +struct LocationWelcomeOverlay: View { + let presentation: LocationWelcomeModel.Presentation + let dismissAction: () -> Void + let planStayAction: ((Region) -> Void)? + + @AccessibilityFocusState private var isCardFocused: Bool + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + ZStack { + Color.black + .opacity(stylesheet.locationWelcome.scrimOpacity) + .ignoresSafeArea() + .accessibilityHidden(true) + + GeometryReader { proxy in + ScrollView { + RegionWelcomeCard( + presentation: presentation, + dismissAction: dismissAction, + planStayAction: planStayAction, + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.vertical, stylesheet.spacing.xxxLarge) + .frame(maxWidth: .infinity, minHeight: proxy.size.height) + .accessibilityFocused($isCardFocused) + } + .scrollBounceBehavior(.basedOnSize) + } + } + .contentShape(Rectangle()) + .accessibilityAddTraits(.isModal) + .onAppear { + isCardFocused = true + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + .onDisappear { + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + } +} + +#if DEBUG + #Preview { + LocationWelcomeOverlay( + presentation: .init(region: .california, greeting: .first), + dismissAction: {}, + planStayAction: { _ in }, + ) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index a87cb94ce..b5cb218bf 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -14,8 +14,10 @@ struct LocationsView: View { @State private var showingResolution = false @State private var plannedStayEditorTarget: PlannedStayEditorTarget? + @State private var isLocationsSurfaceVisible = false @State private var isCardSurfaceVisible = false @State private var cardPresentation: LocationCardsPresentationModel + @State private var welcome: LocationWelcomeModel @State private var planning = LocationsPlanningModel() /// Drives the region cards' tilt-reactive light sheen. Started/stopped @@ -34,10 +36,41 @@ struct LocationsView: View { isCardSurfaceVisible && !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, @@ -50,6 +83,8 @@ 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 @@ -75,6 +110,22 @@ struct LocationsView: View { } } } + .accessibilityHidden(welcomePresentation != nil) + .overlay { + if let presentation = welcomePresentation { + LocationWelcomeOverlay( + presentation: presentation, + dismissAction: welcome.dismiss, + planStayAction: welcomePlanStayAction, + ) + .transition(stylesheet.locationWelcome.motion.transition) + } + } + .animation(stylesheet.locationWelcome.motion.animation, value: welcomePresentation) + .task(id: isWelcomeLookupActive) { + guard isWelcomeLookupActive else { return } + await welcome.resolve() + } .onAppear { tilt.start() } .onDisappear { tilt.stop() } .sheet(isPresented: $showingResolution) { @@ -254,6 +305,14 @@ struct LocationsView: View { plannedStayEditorTarget = PlannedStayEditorTarget(region: region) } + private func planStayFromWelcome(_ region: Region) { + withAnimation(stylesheet.locationWelcome.motion.animation) { + welcome.dismiss() + } completion: { + editPlannedStay(region) + } + } + private func clearPlannedStay() { Task { await planning.clear(using: report.forecasts.clear) @@ -389,11 +448,39 @@ private struct ResolveToolbarLabel: View { report: PreviewSupport.loadedYearReportModelWithLocationDotsHidden(), ) } + whereSnapshot( + name: "WelcomeFirst", + configurations: .phoneLightDark + [ + SnapshotConfiguration(dynamicType: .accessibility5, device: .iPhone), + ], + measurementReadiness: .immediate, + ) { + welcomeSnapshot(greeting: .first) + } + whereSnapshot( + name: "WelcomeBack", + configurations: .phoneLightDark, + measurementReadiness: .immediate, + ) { + welcomeSnapshot(greeting: .returnVisit) + } } private static func forecastsHiddenReport() -> YearReportModel { PreviewSupport.loadedYearReportModelWithEstimatedTimeHidden() } + + private static func welcomeSnapshot( + greeting: LocationWelcomeModel.Presentation.Greeting, + ) -> some View { + let report = PreviewSupport.loadedYearReportModel() + let welcome = LocationWelcomeModel( + services: report.services, + preferences: report.preferences, + ) + welcome.presentForTesting(region: .california, greeting: greeting) + return LocationsView(report: report, welcome: welcome) + } } #Preview { diff --git a/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift new file mode 100644 index 000000000..b44485a9c --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionWelcomeCard.swift @@ -0,0 +1,215 @@ +import RegionKit +import SFSafeSymbols +import SwiftUI + +/// A passport-inspired acknowledgement of the user's current tracked region. +struct RegionWelcomeCard: View { + let presentation: LocationWelcomeModel.Presentation + let dismissAction: () -> Void + let planStayAction: ((Region) -> Void)? + + @State private var regionPaths: ArtworkPaths? + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + @Environment(\.regionOutlinePathCache) private var regionOutlinePathCache + + private var welcome: WhereStylesheet.LocationWelcomeStyle { + stylesheet.locationWelcome + } + + private var regionStyle: RegionStyle { + regionStyles.style(for: presentation.region) + } + + private var title: String { + switch presentation.greeting { + case .first: + String(localized: .locationWelcomeFirstTitle(presentation.region.localizedName)) + case .returnVisit: + String(localized: .locationWelcomeReturnTitle(presentation.region.localizedName)) + } + } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: welcome.cornerRadius) + VStack(alignment: .leading, spacing: welcome.contentSpacing) { + HStack { + Text(regionStyle.emoji) + .font(.largeTitle) + .accessibilityHidden(true) + Spacer() + PassportSeal(systemSymbol: regionStyle.symbol, tint: regionStyle.tint) + } + + VStack(alignment: .leading, spacing: stylesheet.spacing.medium) { + Text(title) + .font(.title2.bold()) + .fontDesign(.serif) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: .locationWelcomeMessage( + presentation.region.localizedName, + ))) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + + if planStayAction != nil { + Button( + String(localized: .locationForecastEditStay), + systemSymbol: .calendarBadgeClock, + action: planStay, + ) + .buttonStyle(LocationForecastEndorsementButtonStyle( + tint: regionStyle.tint, + expands: true, + controls: stylesheet.locationForecast.controls, + ink: stylesheet.locationForecast.ink, + )) + } + } + .padding(welcome.padding) + .frame(maxWidth: welcome.maxWidth) + .background { + ZStack { + shape.fill(.clear) + .glassEffect( + .regular.tint(regionStyle.tint.opacity(welcome.glassTintOpacity)), + in: shape, + ) + + shape.fill(.background) + .opacity(welcome.paperOpacity) + + ZStack { + SecurityPrintRosette( + tint: regionStyle.tint, + wobble: stylesheet.card.regular.rosette.wobble, + lineWidth: stylesheet.card.regular.rosette.lineWidth, + primaryRingSpacing: stylesheet.card.regular.rosette.primaryRingSpacing, + secondaryRingSpacing: stylesheet.card.regular.rosette.secondaryRingSpacing, + primaryOpacity: stylesheet.card.rosetteFill.primary, + secondaryOpacity: stylesheet.card.rosetteFill.secondary, + ) + + if + let regionShape = stylesheet.card.regular.regionShape, + let microprint = regionPaths?.microprint, + !microprint.isEmpty + { + RegionOutlineSecurityBorder( + paths: [microprint], + tint: regionStyle.tint, + cornerRadius: welcome.cornerRadius, + inset: regionShape.securityBorder.inset, + glyphSize: regionShape.securityBorder.glyphSize, + spacing: regionShape.securityBorder.spacing, + opacity: regionShape.securityBorder.opacity, + ) + } + + if + let artwork = stylesheet.card.regular.regionShape, + let watermark = regionPaths?.watermark, + !watermark.isEmpty + { + RegionOutlineArtwork( + path: watermark, + tint: regionStyle.tint, + style: artwork.watermark, + ) + } + } + .blendMode(stylesheet.card.securityPrint.backgroundBlendMode) + } + .clipShape(shape) + .allowsHitTesting(false) + } + .overlay(alignment: .topTrailing) { + Button( + String(localized: .locationWelcomeDismiss), + systemSymbol: .xmark, + action: dismissAction, + ) + .labelStyle(.iconOnly) + .font(.system(size: 17, weight: .semibold)) + .frame(width: 44, height: 44) + .background { + Circle() + .fill(.background) + .opacity(welcome.paperOpacity) + } + .buttonStyle(.plain) + .glassEffect( + .regular.tint(regionStyle.tint.opacity(welcome.close.tintOpacity)) + .interactive(), + in: Circle(), + ) + .contentShape(Circle()) + .shadow( + color: regionStyle.tint.opacity(welcome.close.glow.opacity), + radius: welcome.close.glow.radius, + y: welcome.close.glow.offsetY, + ) + .shadow( + color: .black.opacity(welcome.close.lift.opacity), + radius: welcome.close.lift.radius, + y: welcome.close.lift.offsetY, + ) + .offset(welcome.close.offset) + } + .shadow( + color: regionStyle.tint.opacity(welcome.glow.opacity), + radius: welcome.glow.radius, + y: welcome.glow.offsetY, + ) + .shadow( + color: .black.opacity(welcome.lift.opacity), + radius: welcome.lift.radius, + y: welcome.lift.offsetY, + ) + .task(id: presentation.region) { + guard let regionOutlinePathCache else { return } + let region = presentation.region + async let watermark = regionOutlinePathCache.path( + for: region, + resolution: .medium, + ) + async let microprint = regionOutlinePathCache.path( + for: region, + resolution: .micro, + ) + let (watermarkPath, microprintPath) = await (watermark, microprint) + let loaded = ArtworkPaths( + watermark: watermarkPath, + microprint: microprintPath, + ) + guard !Task.isCancelled else { return } + regionPaths = loaded + } + } + + private func planStay() { + planStayAction?(presentation.region) + } + + private struct ArtworkPaths { + let watermark: Path + let microprint: Path + } +} + +#if DEBUG + #Preview { + RegionWelcomeCard( + presentation: .init(region: .california, greeting: .returnVisit), + dismissAction: {}, + planStayAction: { _ in }, + ) + .padding(32) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 0e27c08a3..fb4a9878f 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3571,6 +3571,54 @@ } } }, + "locationWelcome.dismiss" : { + "comment" : "Accessibility label for the close button on the live-region welcome card.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close welcome" + } + } + } + }, + "locationWelcome.firstTitle" : { + "comment" : "Title shown for the first live tracked region. The argument is the localized region name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to %@" + } + } + } + }, + "locationWelcome.message" : { + "comment" : "Explains automatic day counting. The argument is the localized region name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starting today, days Where records you here will count toward %@." + } + } + } + }, + "locationWelcome.returnTitle" : { + "comment" : "Title shown after the user moves to another tracked region. The argument is the localized region name.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome back to %@" + } + } + } + }, "locations.elsewhere.subtitle" : { "comment" : "Subtitle on the Locations tab's Elsewhere entry card: region count.", "extractionState" : "manual", @@ -6101,6 +6149,30 @@ } } }, + "settings.appearance.locationWelcome.footer" : { + "comment" : "Explains when the live-region welcome card appears.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show a welcome card when Where starts counting days toward a different tracked region." + } + } + } + }, + "settings.appearance.locationWelcome.toggle" : { + "comment" : "Toggle that controls whether live-region welcome cards appear on the Locations tab.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Region welcome cards" + } + } + } + }, "settings.appearance.theme.footer" : { "extractionState" : "manual", "localizations" : { @@ -8494,6 +8566,18 @@ } } }, + "settings.keywords.locationWelcome" : { + "comment" : "Comma-separated Settings search keywords for the live-region welcome visibility toggle.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "welcome, greeting, region, location, card, arrival" + } + } + } + }, "settings.keywords.loggedDays" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift index f81d63673..5e082bdc7 100644 --- a/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift +++ b/Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift @@ -3,8 +3,8 @@ import SnapshotKit import SwiftUI import WhereCore -/// Settings drill-in for presentation choices: theme, Locations-card overlays, -/// and alternate app icon. +/// Settings drill-in for presentation choices: theme, Locations-card overlays +/// and welcomes, and alternate app icon. struct AppearanceSettingsView: View { let report: YearReportModel var focus: SettingsFocus? @@ -52,6 +52,18 @@ struct AppearanceSettingsView: View { Text(String(localized: .settingsAppearanceLocationDotsFooter)) } + Section { + Toggle(isOn: $report.showsLocationWelcome) { + Label( + String(localized: .settingsAppearanceLocationWelcomeToggle), + systemSymbol: .sparkles, + ) + } + .settingsRow(Item.locationWelcome) + } footer: { + Text(String(localized: .settingsAppearanceLocationWelcomeFooter)) + } + Section { Toggle(isOn: $estimatedTimeSettings.isEnabled) { HStack { @@ -141,6 +153,7 @@ extension AppearanceSettingsView: SettingsSection { enum Item: SettingsItem { case theme case locationDots + case locationWelcome case locationForecasts case appIcon #if DEBUG @@ -153,6 +166,8 @@ extension AppearanceSettingsView: SettingsSection { case .theme: String(localized: .settingsAppearanceThemeHeader) case .locationDots: String(localized: .settingsAppearanceLocationDotsToggle) + case .locationWelcome: + String(localized: .settingsAppearanceLocationWelcomeToggle) case .locationForecasts: String(localized: .settingsAppearanceLocationForecastsToggle) case .appIcon: String(localized: .settingsAppIconLink) @@ -169,6 +184,8 @@ extension AppearanceSettingsView: SettingsSection { splitKeywords(String(localized: .settingsKeywordsTheme)) case .locationDots: splitKeywords(String(localized: .settingsKeywordsLocationDots)) + case .locationWelcome: + splitKeywords(String(localized: .settingsKeywordsLocationWelcome)) case .locationForecasts: splitKeywords(String(localized: .settingsKeywordsLocationForecasts)) case .appIcon: splitKeywords(String(localized: .settingsKeywordsAppIcon)) diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index b6e674fd8..638eb218e 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -18,6 +18,7 @@ struct WhereStylesheet: BStylesheet { var size = Size() var card = CardStyles.standard var locationCardStack = LocationCardStackStyle.standard + var locationWelcome = LocationWelcomeStyle.standard var calendar = CalendarStyle.standard var appIcon = AppIconStyle.standard var timeline = TimelineStyle.standard @@ -82,6 +83,7 @@ struct WhereStylesheet: BStylesheet { if traits.accessibility.isReduceMotionEnabled { card.dayCount = .reducedMotion locationCardStack.overtake = .reducedMotion + locationWelcome.motion = .reduced developerOverlay.menu.motion = .reduced } @@ -98,6 +100,89 @@ struct WhereStylesheet: BStylesheet { static let `default` = WhereStylesheet() } +// MARK: - Location welcome + +extension WhereStylesheet { + /// Appearance and motion for the live-region welcome over Locations. + struct LocationWelcomeStyle: Equatable { + var maxWidth: CGFloat + var cornerRadius: CGFloat + var padding: CGFloat + var contentSpacing: CGFloat + var paperOpacity: Double + var scrimOpacity: Double + var glassTintOpacity: Double + var glow: Shadow + var lift: Shadow + var close: Close + var motion: Motion + + struct Shadow: Equatable { + var opacity: Double + var radius: CGFloat + var offsetY: CGFloat = 0 + } + + struct Close: Equatable { + var offset: CGSize + var tintOpacity: Double + var glow: Shadow + var lift: Shadow + } + + struct Motion: Equatable { + var animation: Animation + var scale: CGFloat + var verticalOffset: CGFloat + var usesSpatialMotion: Bool + + var transition: AnyTransition { + let base: AnyTransition = usesSpatialMotion + ? .scale(scale: scale).combined(with: .offset(y: verticalOffset)) + .combined(with: .opacity) + : .opacity + return .asymmetric( + insertion: base.animation(animation), + removal: base.animation(animation), + ) + } + + static let standard = Motion( + animation: .spring(duration: 0.62, bounce: 0.32), + scale: 0.78, + verticalOffset: 34, + usesSpatialMotion: true, + ) + + static let reduced = Motion( + animation: .easeInOut(duration: 0.18), + scale: 1, + verticalOffset: 0, + usesSpatialMotion: false, + ) + } + + static let standard = LocationWelcomeStyle( + maxWidth: 390, + cornerRadius: 30, + padding: 24, + contentSpacing: 16, + paperOpacity: 0.92, + scrimOpacity: 0.28, + glassTintOpacity: 0.2, + glow: Shadow(opacity: 0.16, radius: 22), + lift: Shadow(opacity: 0.18, radius: 12, offsetY: 6), + close: Close( + offset: CGSize(width: 8, height: -8), + tintOpacity: 0.24, + glow: .init(opacity: 0.28, radius: 8), + lift: .init(opacity: 0.22, radius: 5, offsetY: 3), + ), + motion: .standard, + ) + } +} + // MARK: - Location card stack extension WhereStylesheet { diff --git a/Where/WhereUI/Tests/LocationWelcomeModelTests.swift b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift new file mode 100644 index 000000000..aabf3e855 --- /dev/null +++ b/Where/WhereUI/Tests/LocationWelcomeModelTests.swift @@ -0,0 +1,146 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@testable import WhereUI + +@MainActor +struct LocationWelcomeModelTests { + @Test func firstResolvedRegionPresentsAFirstGreeting() async throws { + let fixture = try await fixture(region: .california) + + await fixture.model.resolve() + + #expect(fixture.model.presentation == .init(region: .california, greeting: .first)) + } + + @Test func dismissalPersistsAndSuppressesTheSameRegion() async throws { + let fixture = try await fixture(region: .california) + await fixture.model.resolve() + fixture.model.dismiss() + + let relaunched = LocationWelcomeModel( + services: fixture.services, + preferences: fixture.preferences, + ) + await relaunched.resolve() + + #expect(fixture.preferences.lastWelcomedRegion == .california) + #expect(relaunched.presentation == nil) + } + + @Test func differentRegionPresentsAReturnGreeting() async throws { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.lastWelcomedRegion = .california + let fixture = try await fixture(region: .newYork, preferences: preferences) + + await fixture.model.resolve() + + #expect(fixture.model.presentation == .init(region: .newYork, greeting: .returnVisit)) + } + + @Test func inactiveRecordingDoesNotPresent() async throws { + let fixture = try fixtureWithoutRecording(region: .california) + + await fixture.model.resolve() + #expect(fixture.model.presentation == nil) + } + + @Test func disabledPreferenceDoesNotPresent() async throws { + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + preferences.showsLocationWelcome = false + let fixture = try await fixture(region: .california, preferences: preferences) + + await fixture.model.resolve() + + #expect(fixture.model.presentation == nil) + } + + @Test func disabledDuringResolutionDoesNotPublishALateWelcome() async throws { + let source = GatedCurrentLocationSource() + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try Self.services(locationSource: source) + try await services.ingestor.authorizeRecording() + let model = LocationWelcomeModel(services: services, preferences: preferences) + let task = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + + preferences.showsLocationWelcome = false + try await source.resolveRequest( + at: 0, + with: Self.sample(region: .california), + ) + await task.value + + #expect(model.presentation == nil) + } + + @Test func cancelledResolutionDoesNotPublishALateRegion() async throws { + let source = GatedCurrentLocationSource() + let preferences = WherePreferences(store: InMemoryKeyValueStore()) + let services = try Self.services(locationSource: source) + try await services.ingestor.authorizeRecording() + let model = LocationWelcomeModel(services: services, preferences: preferences) + let task = Task { await model.resolve() } + await source.waitUntilRequestCount(1) + + task.cancel() + try await source.resolveRequest( + at: 0, + with: Self.sample(region: .california), + ) + await task.value + + #expect(model.presentation == nil) + } + + private func fixture( + region: Region, + preferences: WherePreferences = WherePreferences(store: InMemoryKeyValueStore()), + ) async throws -> Fixture { + let fixture = try fixtureWithoutRecording(region: region, preferences: preferences) + try await fixture.services.ingestor.authorizeRecording() + return fixture + } + + private func fixtureWithoutRecording( + region: Region, + preferences: WherePreferences = WherePreferences(store: InMemoryKeyValueStore()), + ) throws -> Fixture { + let source = ScriptedLocationSource() + try source.setNextRequestedLocation(Self.sample(region: region)) + let services = try Self.services(locationSource: source) + return Fixture( + model: LocationWelcomeModel(services: services, preferences: preferences), + services: services, + preferences: preferences, + ) + } + + private static func services(locationSource: any LocationSource) throws -> WhereServices { + try WhereServices( + store: SwiftDataStore.inMemory(), + locationSource: locationSource, + ) + } + + private static func sample(region: Region) throws -> LocationSample { + let coordinates = [ + Region.california: Coordinate(latitude: 37.7749, longitude: -122.4194), + Region.newYork: Coordinate(latitude: 40.7128, longitude: -74.0060), + ] + let coordinate = try #require(coordinates[region]) + return LocationSample( + timestamp: Date(timeIntervalSinceReferenceDate: 0), + coordinate: coordinate, + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + } + + private struct Fixture { + let model: LocationWelcomeModel + let services: WhereServices + let preferences: WherePreferences + } +} diff --git a/Where/WhereUI/Tests/SettingsSearchTests.swift b/Where/WhereUI/Tests/SettingsSearchTests.swift index d89b514ad..724f6ffd4 100644 --- a/Where/WhereUI/Tests/SettingsSearchTests.swift +++ b/Where/WhereUI/Tests/SettingsSearchTests.swift @@ -59,6 +59,15 @@ struct SettingsSearchTests { }) } + @Test func matchesLocationWelcomeVisibilityOnGreetingKeyword() { + let results = SettingsCatalog.results(matching: "greeting") + + #expect(results.contains { + $0.destination == .appearance + && $0.title == String(localized: .settingsAppearanceLocationWelcomeToggle) + }) + } + @Test func matchesTheRankingAnimationLabOnMotionKeyword() { let results = SettingsCatalog.results(matching: "overtake") diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index a7d75d2be..208297c6d 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -45,6 +45,24 @@ struct WhereStylesheetTests { #expect(style.spacing.xxxLarge == 20) } + @Test func locationWelcomeStyle() { + let welcome = style.locationWelcome + #expect(welcome.maxWidth == 390) + #expect(welcome.cornerRadius == 30) + #expect(welcome.padding == 24) + #expect(welcome.contentSpacing == 16) + #expect(welcome.paperOpacity == 0.92) + #expect(welcome.scrimOpacity == 0.28) + #expect(welcome.glassTintOpacity == 0.2) + #expect(welcome.glow == .init(opacity: 0.16, radius: 22)) + #expect(welcome.lift == .init(opacity: 0.18, radius: 12, offsetY: 6)) + #expect(welcome.close.offset == CGSize(width: 8, height: -8)) + #expect(welcome.close.tintOpacity == 0.24) + #expect(welcome.close.glow == .init(opacity: 0.28, radius: 8)) + #expect(welcome.close.lift == .init(opacity: 0.22, radius: 5, offsetY: 3)) + #expect(welcome.motion == .standard) + } + @Test func regularCardStyle() { let card = style.card.regular #expect(style.card.estimatedProgressOpacity == 0.3) @@ -822,6 +840,8 @@ struct WhereStylesheetTests { #expect(resolved.locationCardStack.overtake == .reducedMotion) #expect(resolved.locationCardStack.overtake.minimumOpacity == 0.82) #expect(resolved.locationCardStack.overtake.usesSpatialMotion == false) + #expect(resolved.locationWelcome.motion == .reduced) + #expect(resolved.locationWelcome.motion.usesSpatialMotion == false) #expect(resolved.developerOverlay.menu.motion == .reduced) #expect(resolved.developerOverlay.menu.motion.usesSpatialMotion == false) } diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index cbb74df44..2fd0c3597 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -294,6 +294,21 @@ struct YearReportModelTests { #expect(preferences.showsEstimatedTimeAndPlanning) } + @Test func locationWelcomeVisibilityMirrorsAndPersists() throws { + let preferences = makePreferences() + preferences.showsLocationWelcome = false + let report = try YearReportModel( + services: makeServices(), + selectedYear: 2026, + preferences: preferences, + ) + + #expect(report.showsLocationWelcome == false) + + report.showsLocationWelcome = true + #expect(preferences.showsLocationWelcome) + } + /// The Resolve list keys its scan `.task(id:)` on `dataIssueScanInputs`, so a /// drift-threshold change must change that identity — otherwise the list keeps /// a stale scan while the badge count moves and the two visibly disagree. The