From c9af47459be87aaa8a3b1069e53564d3254abd16 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:38:50 -0700 Subject: [PATCH 01/22] Add public SwiftDataStore.make(storage:) factory for app wiring The @ModelActor-generated init(modelContainer:) is not reachable from other modules, so WhereUI had no supported way to build a production (.localOnly/.cloudKit) store. Add a public make(storage:) factory mirroring inMemory(), plus a round-trip test. Co-authored-by: Cursor --- .../Sources/Persistence/SwiftDataStore.swift | 11 +++++++++++ Where/WhereCore/Tests/WhereCoreTests.swift | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 7fcc1b3..0418e28 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -105,6 +105,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { return SwiftDataStore(modelContainer: container) } + /// App-wiring factory: builds a store for the given storage mode + /// (defaulting to the build/test-aware `Storage.default`) and wraps + /// it in a `SwiftDataStore`. The `@ModelActor`-generated + /// `init(modelContainer:)` is not reachable from other modules, so + /// this is the supported entry point for production wiring in the + /// app/UI layer. + public static func make(storage: Storage = .default) throws -> SwiftDataStore { + let container = try makeContainer(storage: storage) + return SwiftDataStore(modelContainer: container) + } + private static let logger = Logger(subsystem: "com.stuff.where", category: "SwiftDataStore") /// Peer `ModelContext` active for the duration of an outermost diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index b0a6075..e1fe575 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -27,6 +27,20 @@ struct StorageDefaultTests { // write to the user's local SwiftData store β€” bad. #expect(SwiftDataStore.Storage.default == .inMemory) } + + @Test func make_inMemory_roundTripsASample() async throws { + let store = try SwiftDataStore.make(storage: .inMemory) + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + coordinate: Coordinate(latitude: 37.7749, longitude: -122.4194), + horizontalAccuracy: 10, + source: .manual, + ) + try await store.perform { try await store.add(sample: sample) } + + let stored = try await store.allSamples() + #expect(stored.map(\.id) == [sample.id]) + } } struct RegionTests { From 13cc497056d5385d0bbb0d3a312ae8ecc8904662 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:42:11 -0700 Subject: [PATCH 02/22] Add WhereUI model layer: WhereModel, RegionRanking, RegionStyle, previews Groundwork for the tabbed UI (no behavior change yet): - WhereModel (@MainActor @Observable) wraps WhereController and exposes year selection, report loading, manual-day entry, GPS, and clear-year. - RegionRanking purely splits a YearReport into primary (top regions by days, excluding .other) and secondary. - RegionStyle gives each Region a symbol/emoji/tint for whimsy. - PreviewSupport seeds in-memory fixtures for #Preview and tests. Co-authored-by: Cursor --- .../WhereUI/Sources/Model/RegionRanking.swift | 68 ++++++++ Where/WhereUI/Sources/Model/RegionStyle.swift | 39 +++++ Where/WhereUI/Sources/Model/WhereModel.swift | 145 ++++++++++++++++++ .../Sources/Preview/PreviewSupport.swift | 75 +++++++++ 4 files changed, 327 insertions(+) create mode 100644 Where/WhereUI/Sources/Model/RegionRanking.swift create mode 100644 Where/WhereUI/Sources/Model/RegionStyle.swift create mode 100644 Where/WhereUI/Sources/Model/WhereModel.swift create mode 100644 Where/WhereUI/Sources/Preview/PreviewSupport.swift diff --git a/Where/WhereUI/Sources/Model/RegionRanking.swift b/Where/WhereUI/Sources/Model/RegionRanking.swift new file mode 100644 index 0000000..7df50e8 --- /dev/null +++ b/Where/WhereUI/Sources/Model/RegionRanking.swift @@ -0,0 +1,68 @@ +import Foundation +import WhereCore + +/// A region paired with the number of calendar days the user was present in +/// it during a year. A small named struct rather than a tuple, per the +/// repo's value-type conventions. +public struct RegionDays: Hashable, Sendable, Identifiable { + public let region: Region + public let days: Int + + public var id: Region { + region + } + + public init(region: Region, days: Int) { + self.region = region + self.days = days + } +} + +/// Splits a `YearReport` into "primary" (the regions you spend the most days +/// in) and "secondary" (everywhere else you turned up). This is a +/// presentation concept β€” the domain layer only knows day counts β€” so it +/// lives in `WhereUI`. +public struct RegionRanking: Hashable, Sendable { + /// How many top-ranked regions are treated as "primary". + public static let primaryCount = 2 + + public let primary: [RegionDays] + public let secondary: [RegionDays] + + public init(primary: [RegionDays], secondary: [RegionDays]) { + self.primary = primary + self.secondary = secondary + } + + /// Build a ranking from a report. Regions with zero days are dropped. + /// `.other` is never "primary" (it's a catch-all bucket, not a place you + /// live), so it always falls into `secondary` when present. + public init(report: YearReport, primaryCount: Int = RegionRanking.primaryCount) { + let ranked = RegionRanking.ranked(report: report) + let primary = Array( + ranked.filter { $0.region != .other }.prefix(max(0, primaryCount)), + ) + let primaryRegions = Set(primary.map(\.region)) + let secondary = ranked.filter { !primaryRegions.contains($0.region) } + self.init(primary: primary, secondary: secondary) + } + + /// All present regions sorted by day count descending, ties broken by + /// `Region.allCases` declaration order so the layout is stable. + static func ranked(report: YearReport) -> [RegionDays] { + let order = Dictionary( + uniqueKeysWithValues: Region.allCases.enumerated().map { ($1, $0) }, + ) + return report.totals + .filter { $0.value > 0 } + .map { RegionDays(region: $0.key, days: $0.value) } + .sorted { lhs, rhs in + if lhs.days != rhs.days { return lhs.days > rhs.days } + return (order[lhs.region] ?? 0) < (order[rhs.region] ?? 0) + } + } + + public var isEmpty: Bool { + primary.isEmpty && secondary.isEmpty + } +} diff --git a/Where/WhereUI/Sources/Model/RegionStyle.swift b/Where/WhereUI/Sources/Model/RegionStyle.swift new file mode 100644 index 0000000..f9a3079 --- /dev/null +++ b/Where/WhereUI/Sources/Model/RegionStyle.swift @@ -0,0 +1,39 @@ +import SwiftUI +import WhereCore + +/// Whimsical, location-themed styling for a `Region`: an SF Symbol, a playful +/// emoji, and an accent color. Pure presentation, so it lives in `WhereUI` +/// alongside the views it decorates. +public struct RegionStyle: Sendable { + public let symbolName: String + public let emoji: String + public let tint: Color + + public init(symbolName: String, emoji: String, tint: Color) { + self.symbolName = symbolName + self.emoji = emoji + self.tint = tint + } + + public static func style(for region: Region) -> RegionStyle { + switch region { + case .california: + RegionStyle(symbolName: "sun.max.fill", emoji: "🌴", tint: .orange) + case .newYork: + RegionStyle(symbolName: "building.2.fill", emoji: "πŸ—½", tint: .indigo) + case .canada: + RegionStyle(symbolName: "leaf.fill", emoji: "🍁", tint: .red) + case .europeanUnion: + RegionStyle(symbolName: "star.circle.fill", emoji: "πŸ‡ͺπŸ‡Ί", tint: .blue) + case .other: + RegionStyle(symbolName: "location.magnifyingglass", emoji: "🧭", tint: .teal) + } + } +} + +extension Region { + /// Convenience accessor so views can write `region.style`. + public var style: RegionStyle { + RegionStyle.style(for: self) + } +} diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift new file mode 100644 index 0000000..f2a4f2a --- /dev/null +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -0,0 +1,145 @@ +import Foundation +import Observation +import WhereCore + +/// Observable view-model bridging the SwiftUI layer to the `WhereController` +/// actor. Owns the selected year, the loaded `YearReport`, and the GPS / +/// permission state, and funnels every mutation through the controller so the +/// views stay free of persistence and CoreLocation details. +@MainActor +@Observable +public final class WhereModel { + /// Where the current year's data is in its load lifecycle. `failed` + /// carries a user-presentable message. + public enum LoadState: Equatable { + case idle + case loading + case loaded + case failed(String) + } + + public private(set) var selectedYear: Int + public private(set) var report: YearReport? + public private(set) var loadState: LoadState = .idle + public private(set) var isTracking = false + + /// Set when a location-permission request comes back denied/restricted, + /// so the UI can offer to open Settings. + public var permissionDenied = false + + private var controller: WhereController? + + /// Primary/secondary split of the current report, or an empty ranking + /// while nothing is loaded. + public var ranking: RegionRanking { + guard let report else { return RegionRanking(primary: [], secondary: []) } + return RegionRanking(report: report) + } + + /// Total distinct days with any tracked presence in the loaded year. + public var trackedDayCount: Int { + report?.days.count ?? 0 + } + + public static var currentYear: Int { + Calendar.current.component(.year, from: Date()) + } + + public init(selectedYear: Int = WhereModel.currentYear) { + self.selectedYear = selectedYear + } + + /// Preview/test seam: inject an already-built controller (and optionally a + /// preloaded report) so SwiftUI previews and unit tests skip the live + /// SwiftData + CoreLocation wiring. + public init( + controller: WhereController, + report: YearReport? = nil, + selectedYear: Int = WhereModel.currentYear, + ) { + self.controller = controller + self.report = report + self.selectedYear = selectedYear + loadState = report == nil ? .idle : .loaded + } + + /// Build the production controller (SwiftData + CoreLocation) on first + /// appearance, then load the selected year. Safe to call repeatedly; the + /// controller is only built once. + public func start() async { + if controller == nil { + do { + let store = try SwiftDataStore.make() + controller = WhereController( + store: store, + locationSource: CoreLocationSource(), + ) + } catch { + loadState = .failed(error.localizedDescription) + return + } + } + await refresh() + } + + public func select(year: Int) async { + guard year != selectedYear else { return } + selectedYear = year + await refresh() + } + + public func refresh() async { + guard let controller else { return } + loadState = .loading + do { + report = try await controller.yearReport(for: selectedYear) + loadState = .loaded + } catch { + loadState = .failed(error.localizedDescription) + } + } + + public func setManualDay(date: Date, regions: Set) async { + guard let controller else { return } + do { + try await controller.addManualDay(date: date, regions: regions) + await refresh() + } catch { + loadState = .failed(error.localizedDescription) + } + } + + public func requestPermission() async { + guard let controller else { return } + do { + try await controller.requestLocationPermission() + permissionDenied = false + } catch { + // Both `LocationPermissionDeniedError` and any unexpected failure + // mean we don't have Always access, so route the user to Settings. + permissionDenied = true + } + } + + public func startTracking() async { + guard let controller else { return } + await controller.startGPS() + isTracking = true + } + + public func stopTracking() async { + guard let controller else { return } + await controller.stopGPS() + isTracking = false + } + + public func clearSelectedYear() async { + guard let controller else { return } + do { + try await controller.clearYear(selectedYear) + await refresh() + } catch { + loadState = .failed(error.localizedDescription) + } + } +} diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift new file mode 100644 index 0000000..ceccc3b --- /dev/null +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -0,0 +1,75 @@ +#if DEBUG + import Foundation + import WhereCore + + /// Preview/test fixtures for `WhereUI`. Provides both a synchronous sample + /// `YearReport` (for static display previews) and an in-memory + /// `WhereController` seeded via `addManualDay` (for interactive previews + /// that exercise the live read path) β€” neither touches disk, CloudKit, or + /// CoreLocation. + public enum PreviewSupport { + public static let year = 2026 + + /// How many days each region gets in the sample data. CA/NY heavy so + /// the primary/secondary split is obvious. + static let spread: [RegionDays] = [ + RegionDays(region: .california, days: 148), + RegionDays(region: .newYork, days: 96), + RegionDays(region: .canada, days: 21), + RegionDays(region: .europeanUnion, days: 13), + RegionDays(region: .other, days: 7), + ] + + /// A believable `YearReport` built directly (no controller needed), so + /// `#Preview` blocks can render real content synchronously. + public static func sampleReport() -> YearReport { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let startOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! + + var days: [DayPresence] = [] + var totals: [Region: Int] = [:] + var dayOffset = 0 + for entry in spread { + totals[entry.region] = entry.days + for _ in 0 ..< entry.days { + let date = calendar.date(byAdding: .day, value: dayOffset, to: startOfYear)! + days.append(DayPresence(date: date, regions: [entry.region])) + dayOffset += 1 + } + } + return YearReport(year: year, days: days, totals: totals) + } + + /// A ready-to-render model with the sample report injected and an + /// in-memory controller behind it. Synchronous, so it drops straight + /// into `#Preview`. + @MainActor + public static func loadedModel() -> WhereModel { + let controller = WhereController( + store: try! SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + ) + return WhereModel( + controller: controller, + report: sampleReport(), + selectedYear: year, + ) + } + + /// An empty model (in-memory controller, no data) for empty-state + /// previews. + @MainActor + public static func emptyModel() -> WhereModel { + let controller = WhereController( + store: try! SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + ) + return WhereModel( + controller: controller, + report: YearReport(year: year, days: [], totals: [:]), + selectedYear: year, + ) + } + } +#endif From a6e61700bc5f042d51c8933922cf9b8ef514d9b2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:45:47 -0700 Subject: [PATCH 03/22] Replace RootView with a Liquid Glass TabView RootView now hosts three tabs (Primary / Elsewhere / Settings) in an iOS 26 TabView with tab-bar minimize-on-scroll, owns the single WhereModel, builds the live controller in .task, and injects the model via the environment. Primary/Secondary/Settings land as thin stubs that are fleshed out in the following steps. Co-authored-by: Cursor --- .../WhereUI/Sources/Primary/PrimaryView.swift | 16 ++++++++++ Where/WhereUI/Sources/RootView.swift | 29 +++++++++++++++++-- .../Sources/Secondary/SecondaryView.swift | 15 ++++++++++ .../Sources/Settings/SettingsView.swift | 15 ++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/PrimaryView.swift create mode 100644 Where/WhereUI/Sources/Secondary/SecondaryView.swift create mode 100644 Where/WhereUI/Sources/Settings/SettingsView.swift diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift new file mode 100644 index 0000000..2dedde4 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -0,0 +1,16 @@ +import SwiftUI +import WhereCore + +/// Home tab: the regions you spend the most days in. Filled out in a later +/// step. +struct PrimaryView: View { + @Environment(WhereModel.self) private var model + + var body: some View { + NavigationStack { + Text("Primary") + .navigationTitle("Where") + .accessibilityIdentifier("where_root_title") + } + } +} diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 626ffa1..482946a 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -1,11 +1,36 @@ import SwiftUI import WhereCore +/// The app's root: a Liquid Glass tab bar over the three top-level screens. +/// Owns the single `WhereModel`, builds the live controller on appear, and +/// hands the model down through the environment. public struct RootView: View { + @State private var model = WhereModel() + public init() {} public var body: some View { - Text("Where") - .accessibilityIdentifier("where_root_title") + TabView { + Tab("Primary", systemImage: "star.fill") { + PrimaryView() + } + + Tab("Elsewhere", systemImage: "globe.americas.fill") { + SecondaryView() + } + + Tab("Settings", systemImage: "gearshape.fill") { + SettingsView() + } + } + .tabBarMinimizeBehavior(.onScrollDown) + .environment(model) + .task { await model.start() } } } + +#if DEBUG + #Preview { + RootView() + } +#endif diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift new file mode 100644 index 0000000..46f7ced --- /dev/null +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -0,0 +1,15 @@ +import SwiftUI +import WhereCore + +/// Elsewhere tab: everywhere outside your primary regions. Filled out in a +/// later step. +struct SecondaryView: View { + @Environment(WhereModel.self) private var model + + var body: some View { + NavigationStack { + Text("Elsewhere") + .navigationTitle("Elsewhere") + } + } +} diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift new file mode 100644 index 0000000..e9351ef --- /dev/null +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -0,0 +1,15 @@ +import SwiftUI +import WhereCore + +/// Settings tab: manual entry, GPS controls, and destructive actions. Filled +/// out in a later step. +struct SettingsView: View { + @Environment(WhereModel.self) private var model + + var body: some View { + NavigationStack { + Text("Settings") + .navigationTitle("Settings") + } + } +} From b4489e21197e58d5c09c32aa5e97adf761bdb870 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:48:40 -0700 Subject: [PATCH 04/22] Build Primary tab with Liquid Glass region cards PrimaryView shows the top regions (by days) for the selected year as prominent glass RegionSummaryCards inside a GlassEffectContainer, with a playful hero header, day-count headlines, loading/empty/error states, and a shared YearSelector in the toolbar. Co-authored-by: Cursor --- .../WhereUI/Sources/Primary/PrimaryView.swift | 97 ++++++++++++++++- .../Sources/Primary/RegionSummaryCard.swift | 102 ++++++++++++++++++ .../WhereUI/Sources/Shared/YearSelector.swift | 33 ++++++ 3 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 Where/WhereUI/Sources/Primary/RegionSummaryCard.swift create mode 100644 Where/WhereUI/Sources/Shared/YearSelector.swift diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 2dedde4..19a01d6 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -1,16 +1,105 @@ import SwiftUI import WhereCore -/// Home tab: the regions you spend the most days in. Filled out in a later -/// step. +/// Home tab: the regions you spend the most days in for the selected year, +/// shown as prominent Liquid Glass cards. struct PrimaryView: View { @Environment(WhereModel.self) private var model var body: some View { NavigationStack { - Text("Primary") + screen .navigationTitle("Where") - .accessibilityIdentifier("where_root_title") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + YearSelector() + } + } + } + } + + @ViewBuilder + private var screen: some View { + switch model.loadState { + case .loading where model.report == nil: + ProgressView("Charting your year…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(message): + ContentUnavailableView { + Label("Couldn't load your year", systemImage: "exclamationmark.icloud") + } description: { + Text(message) + } + default: + if model.ranking.primary.isEmpty { + emptyState + } else { + content + } + } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + GlassEffectContainer(spacing: 16) { + VStack(spacing: 16) { + ForEach( + Array(model.ranking.primary.enumerated()), + id: \.element.id, + ) { index, item in + RegionSummaryCard(regionDays: item, caption: caption(forRank: index)) + } + } + } + } + .padding() + } + .accessibilityIdentifier("where_root_title") + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: "Where have you been in \(model.selectedYear)?") + .font(.largeTitle.bold()) + Text(verbatim: "\(model.trackedDayCount) days on the map so far") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var emptyState: some View { + ContentUnavailableView { + Label(noTravelsTitle, systemImage: "map") + } description: { + Text("Turn on tracking or add a day in Settings and your top spots will land here.") + } + } + + private var noTravelsTitle: String { + "No travels logged for \(model.selectedYear)" + } + + /// Playful rank labels for the top regions. + private func caption(forRank rank: Int) -> String? { + switch rank { + case 0: "Home base" + case 1: "Second home" + default: nil } } } + +#if DEBUG + #Preview("Loaded") { + PrimaryView() + .environment(PreviewSupport.loadedModel()) + } + + #Preview("Empty") { + PrimaryView() + .environment(PreviewSupport.emptyModel()) + } +#endif diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift new file mode 100644 index 0000000..b60915f --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -0,0 +1,102 @@ +import SwiftUI +import WhereCore + +/// A Liquid Glass card summarizing how many days were spent in one region. +/// Used prominently on the Primary tab and (more compactly) on Elsewhere. +struct RegionSummaryCard: View { + let regionDays: RegionDays + var caption: String? + var compact = false + + /// Days in a typical year; the ambient bar is drawn as a fraction of this. + var yearLength = 365 + + private var style: RegionStyle { + regionDays.region.style + } + + private var fraction: Double { + guard yearLength > 0 else { return 0 } + return min(1, Double(regionDays.days) / Double(yearLength)) + } + + var body: some View { + VStack(alignment: .leading, spacing: compact ? 10 : 16) { + HStack(spacing: 12) { + Text(style.emoji) + .font(compact ? .title2 : .largeTitle) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(regionDays.region.localizedName) + .font(compact ? .headline : .title3.weight(.semibold)) + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + + Image(systemName: style.symbolName) + .font(compact ? .body : .title3) + .foregroundStyle(style.tint) + .accessibilityHidden(true) + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(regionDays.days, format: .number) + .font( + compact + ? .system(.title, design: .rounded, weight: .bold) + : .system(size: 46, weight: .bold, design: .rounded), + ) + .contentTransition(.numericText()) + .foregroundStyle(style.tint) + Text(regionDays.days == 1 ? "day" : "days") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + + Capsule() + .fill(.quaternary) + .frame(height: 6) + .overlay(alignment: .leading) { + GeometryReader { proxy in + Capsule() + .fill(style.tint.gradient) + .frame(width: proxy.size.width * fraction) + } + } + .frame(height: 6) + .accessibilityHidden(true) + } + .padding(compact ? 16 : 22) + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect( + .regular.tint(style.tint.opacity(0.18)), + in: RoundedRectangle(cornerRadius: compact ? 22 : 28, style: .continuous), + ) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(regionDays.region.localizedName): \(regionDays.days) days", + ) + } +} + +#if DEBUG + #Preview { + VStack { + RegionSummaryCard( + regionDays: RegionDays(region: .california, days: 148), + caption: "Home base", + ) + RegionSummaryCard( + regionDays: RegionDays(region: .newYork, days: 22), + compact: true, + ) + } + .padding() + } +#endif diff --git a/Where/WhereUI/Sources/Shared/YearSelector.swift b/Where/WhereUI/Sources/Shared/YearSelector.swift new file mode 100644 index 0000000..38c702e --- /dev/null +++ b/Where/WhereUI/Sources/Shared/YearSelector.swift @@ -0,0 +1,33 @@ +import SwiftUI +import WhereCore + +/// Toolbar control for choosing which calendar year the reports cover. Reads +/// and drives the shared `WhereModel`. +struct YearSelector: View { + @Environment(WhereModel.self) private var model + + private var years: [Int] { + let current = WhereModel.currentYear + return Array((current - 5 ... current).reversed()) + } + + var body: some View { + Menu { + ForEach(years, id: \.self) { year in + Button { + Task { await model.select(year: year) } + } label: { + if year == model.selectedYear { + Label { Text(verbatim: "\(year)") } icon: { Image(systemName: "checkmark") } + } else { + Text(verbatim: "\(year)") + } + } + } + } label: { + Label { Text(verbatim: "\(model.selectedYear)") } icon: { Image(systemName: "calendar") + } + } + .accessibilityIdentifier("where_year_selector") + } +} From a2e725d9c97d0801105aa16f23cede176084bcc1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:49:24 -0700 Subject: [PATCH 05/22] Build Elsewhere tab with compact region cards SecondaryView lists every non-primary region (ranking.secondary) for the selected year as compact glass cards, sharing RegionSummaryCard and the YearSelector, with loading/empty/error states and a light "just passing through" caption for brief stays. Co-authored-by: Cursor --- .../Sources/Secondary/SecondaryView.swift | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 46f7ced..7683ea2 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -1,15 +1,92 @@ import SwiftUI import WhereCore -/// Elsewhere tab: everywhere outside your primary regions. Filled out in a -/// later step. +/// Elsewhere tab: every region outside your primary spots, shown as compact +/// Liquid Glass cards for the selected year. struct SecondaryView: View { @Environment(WhereModel.self) private var model var body: some View { NavigationStack { - Text("Elsewhere") + screen .navigationTitle("Elsewhere") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + YearSelector() + } + } } } + + @ViewBuilder + private var screen: some View { + switch model.loadState { + case .loading where model.report == nil: + ProgressView("Retracing your steps…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(message): + ContentUnavailableView { + Label("Couldn't load your year", systemImage: "exclamationmark.icloud") + } description: { + Text(message) + } + default: + if model.ranking.secondary.isEmpty { + emptyState + } else { + content + } + } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(verbatim: "Everywhere else you turned up in \(model.selectedYear).") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + GlassEffectContainer(spacing: 12) { + VStack(spacing: 12) { + ForEach(model.ranking.secondary) { item in + RegionSummaryCard( + regionDays: item, + caption: caption(for: item), + compact: true, + ) + } + } + } + } + .padding() + } + } + + private var emptyState: some View { + ContentUnavailableView { + Label("Nowhere else logged", systemImage: "globe.americas") + } description: { + Text( + "Spend a day outside your top spots β€” or log a trip in Settings β€” and it'll appear here.", + ) + } + } + + /// Light whimsy for the briefest stays. + private func caption(for item: RegionDays) -> String? { + item.days <= 3 ? "Just passing through" : nil + } } + +#if DEBUG + #Preview("Loaded") { + SecondaryView() + .environment(PreviewSupport.loadedModel()) + } + + #Preview("Empty") { + SecondaryView() + .environment(PreviewSupport.emptyModel()) + } +#endif From 6e95e0ef847af43b44ead46eade87a837c4da8c4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:51:47 -0700 Subject: [PATCH 06/22] Build Settings tab: manual entry, GPS controls, erase-year SettingsView gates location permission (with an Open-Settings alert on denial), a background-tracking toggle wired to WhereController GPS, a push to ManualDayEntryView for asserting/overriding a day's regions, and a confirmed destructive erase of the selected year's data. Co-authored-by: Cursor --- .../Sources/Settings/ManualDayEntryView.swift | 83 ++++++++++++ .../Sources/Settings/SettingsView.swift | 118 +++++++++++++++++- 2 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/ManualDayEntryView.swift diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift new file mode 100644 index 0000000..96f2b59 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -0,0 +1,83 @@ +import SwiftUI +import WhereCore + +/// Retroactively assert which regions a single calendar day belongs to. This +/// overrides any prior manual entry for that day and unions with whatever GPS +/// recorded (see `WhereController.addManualDay`). +struct ManualDayEntryView: View { + @Environment(WhereModel.self) private var model + @Environment(\.dismiss) private var dismiss + + @State private var date = Date() + @State private var selectedRegions: Set = [] + @State private var isSaving = false + + var body: some View { + Form { + Section { + DatePicker( + "Day", + selection: $date, + in: ...Date(), + displayedComponents: .date, + ) + } footer: { + Text("Time travel: tell Where where you really were.") + } + + Section { + ForEach(Region.allCases, id: \.self) { region in + Toggle(isOn: binding(for: region)) { + Label { + Text(region.localizedName) + } icon: { + Text(region.style.emoji) + } + } + } + } header: { + Text("Regions") + } footer: { + Text("Saving replaces any manual regions you previously set for this day.") + } + } + .navigationTitle("Log a Day") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(selectedRegions.isEmpty || isSaving) + } + } + } + + private func binding(for region: Region) -> Binding { + Binding( + get: { selectedRegions.contains(region) }, + set: { isOn in + if isOn { + selectedRegions.insert(region) + } else { + selectedRegions.remove(region) + } + }, + ) + } + + private func save() { + isSaving = true + Task { + await model.setManualDay(date: date, regions: selectedRegions) + dismiss() + } + } +} + +#if DEBUG + #Preview { + NavigationStack { + ManualDayEntryView() + } + .environment(PreviewSupport.loadedModel()) + } +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index e9351ef..bee971f 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,15 +1,125 @@ import SwiftUI +import UIKit import WhereCore -/// Settings tab: manual entry, GPS controls, and destructive actions. Filled -/// out in a later step. +/// Settings tab: location permission + tracking, retroactive manual entry, +/// and the destructive "erase a year" action. struct SettingsView: View { @Environment(WhereModel.self) private var model + @Environment(\.openURL) private var openURL + + @State private var showClearConfirmation = false var body: some View { + @Bindable var model = model + NavigationStack { - Text("Settings") - .navigationTitle("Settings") + Form { + trackingSection + manualEntrySection + dataSection + } + .navigationTitle("Settings") + .alert("Location access needed", isPresented: $model.permissionDenied) { + Button("Open Settings") { openSystemSettings() } + Button("Not now", role: .cancel) {} + } message: { + Text( + "Where needs Always location access to log which region you're in. You can grant it in the Settings app.", + ) + } + } + } + + private var trackingSection: some View { + Section { + Toggle(isOn: trackingBinding) { + Label("Track in the background", systemImage: "location.fill") + } + Button { + Task { await model.requestPermission() } + } label: { + Label("Grant location access", systemImage: "location.magnifyingglass") + } + } header: { + Text("Location") + } footer: { + Text( + "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience.", + ) } } + + private var manualEntrySection: some View { + Section { + NavigationLink { + ManualDayEntryView() + } label: { + Label("Log or override a day", systemImage: "calendar.badge.plus") + } + } header: { + Text("Manual entry") + } footer: { + Text("Backfill a trip the GPS missed, or correct a day by hand.") + } + } + + private var dataSection: some View { + Section { + Button(role: .destructive) { + showClearConfirmation = true + } label: { + Label(eraseTitle, systemImage: "trash") + } + .confirmationDialog( + eraseTitle, + isPresented: $showClearConfirmation, + titleVisibility: .visible, + ) { + Button(eraseTitle, role: .destructive) { + Task { await model.clearSelectedYear() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + verbatim: "This removes every sample, manual day, and piece of evidence in \(model.selectedYear). It can't be undone.", + ) + } + } header: { + Text("Data") + } footer: { + Text(verbatim: "Acts on the year selected on the Primary tab (\(model.selectedYear)).") + } + } + + private var eraseTitle: String { + "Erase \(model.selectedYear) data" + } + + private var trackingBinding: Binding { + Binding( + get: { model.isTracking }, + set: { isOn in + Task { + if isOn { + await model.startTracking() + } else { + await model.stopTracking() + } + } + }, + ) + } + + private func openSystemSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + openURL(url) + } } + +#if DEBUG + #Preview { + SettingsView() + .environment(PreviewSupport.loadedModel()) + } +#endif From de68f9e3eb9847cb037465f5e0c16eb6e395eec4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:52:37 -0700 Subject: [PATCH 07/22] Add location usage descriptions to the Where app Info.plist CoreLocation's requestAlwaysAuthorization requires both NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription, so add them to the Where target's infoPlist; without them the Settings "Grant location access" button would crash on launch of the permission prompt. Co-authored-by: Cursor --- Project.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Project.swift b/Project.swift index 6f9ba0c..5f9b59b 100644 --- a/Project.swift +++ b/Project.swift @@ -50,6 +50,12 @@ let project = Project( infoPlist: .extendingDefault(with: [ "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), + "NSLocationWhenInUseUsageDescription": .string( + "Where uses your location to figure out which region you're in.", + ), + "NSLocationAlwaysAndWhenInUseUsageDescription": .string( + "Where checks your location in the background so it can log which region you're in each day.", + ), ]), sources: ["Where/Where/Sources/**"], resources: ["Where/Where/Resources/**"], From 2728c0055cac5acfc22199cace54e53ea7271b9d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 10:54:41 -0700 Subject: [PATCH 08/22] Test RegionRanking split and host the top-level screens Adds pure unit tests for RegionRanking (top-N primary, .other always secondary, zero-day drop, tie-breaking, empty year, configurable count) and hosted smoke tests that mount Primary/Secondary/Settings with seeded preview data via WhereTesting.show. Co-authored-by: Cursor --- Where/WhereUI/Tests/RegionRankingTests.swift | 71 ++++++++++++++++++++ Where/WhereUI/Tests/ScreenHostingTests.swift | 30 +++++++++ 2 files changed, 101 insertions(+) create mode 100644 Where/WhereUI/Tests/RegionRankingTests.swift create mode 100644 Where/WhereUI/Tests/ScreenHostingTests.swift diff --git a/Where/WhereUI/Tests/RegionRankingTests.swift b/Where/WhereUI/Tests/RegionRankingTests.swift new file mode 100644 index 0000000..94aa026 --- /dev/null +++ b/Where/WhereUI/Tests/RegionRankingTests.swift @@ -0,0 +1,71 @@ +import Testing +import WhereCore +import WhereUI + +struct RegionRankingTests { + private func report(_ totals: [Region: Int]) -> YearReport { + YearReport(year: 2026, days: [], totals: totals) + } + + @Test func topTrackedRegionsArePrimary() { + let ranking = RegionRanking(report: report([ + .california: 100, + .newYork: 60, + .canada: 20, + ])) + #expect(ranking.primary.map(\.region) == [.california, .newYork]) + #expect(ranking.secondary.map(\.region) == [.canada]) + } + + @Test func otherIsNeverPrimaryEvenWhenLargest() { + let ranking = RegionRanking(report: report([ + .other: 200, + .california: 30, + .newYork: 10, + ])) + #expect(ranking.primary.map(\.region) == [.california, .newYork]) + #expect(ranking.secondary.map(\.region) == [.other]) + } + + @Test func zeroDayRegionsAreDropped() { + let ranking = RegionRanking(report: report([ + .california: 5, + .newYork: 0, + ])) + #expect(ranking.primary.map(\.region) == [.california]) + #expect(ranking.secondary.isEmpty) + } + + @Test func tiesBreakByDeclarationOrder() { + let ranking = RegionRanking(report: report([ + .canada: 40, + .newYork: 40, + .california: 40, + ])) + // California precedes New York precedes Canada in `Region.allCases`. + #expect(ranking.primary.map(\.region) == [.california, .newYork]) + #expect(ranking.secondary.map(\.region) == [.canada]) + } + + @Test func emptyReportProducesEmptyRanking() { + let ranking = RegionRanking(report: report([:])) + #expect(ranking.isEmpty) + #expect(ranking.primary.isEmpty) + #expect(ranking.secondary.isEmpty) + } + + @Test func dayCountsArePreserved() { + let ranking = RegionRanking(report: report([.california: 148, .newYork: 96])) + #expect(ranking.primary.first?.days == 148) + #expect(ranking.primary.last?.days == 96) + } + + @Test func primaryCountIsConfigurable() { + let ranking = RegionRanking( + report: report([.california: 100, .newYork: 60, .canada: 20]), + primaryCount: 1, + ) + #expect(ranking.primary.map(\.region) == [.california]) + #expect(ranking.secondary.map(\.region) == [.newYork, .canada]) + } +} diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift new file mode 100644 index 0000000..17415ea --- /dev/null +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -0,0 +1,30 @@ +import SwiftUI +import Testing +import WhereTesting +@testable import WhereUI + +/// Hosts each top-level screen in a real window with seeded preview data to +/// confirm the Liquid Glass layouts mount without crashing. +@MainActor +struct ScreenHostingTests { + @Test func primaryViewHostsWithData() throws { + let model = PreviewSupport.loadedModel() + try show(UIHostingController(rootView: PrimaryView().environment(model))) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func secondaryViewHostsWithData() throws { + let model = PreviewSupport.loadedModel() + try show(UIHostingController(rootView: SecondaryView().environment(model))) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func settingsViewHosts() throws { + let model = PreviewSupport.loadedModel() + try show(UIHostingController(rootView: SettingsView().environment(model))) { hosted in + #expect(hosted.view != nil) + } + } +} From ea19c175386502ddcc86ed692734d291bbbde8e8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 11:04:42 -0700 Subject: [PATCH 09/22] Require a feature branch before committing plan work Codify branching as a pre-commit gate in "Working on plans" so plan to-dos never land directly on main/master by accident. Co-authored-by: Cursor --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4218f91..72a072d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,9 +98,18 @@ pass β€” one commit per to-do, with the test/lint run baked into the "definition of done" for that step. This keeps history bisectable and lets the plan land piecewise if a later step regresses. +Before the first commit, check out a dedicated feature branch β€” never +commit plan work directly onto `main` (or `master`). If you're still on +the base branch, run `git checkout -b ` first. Branch +once at the start of the plan and keep every step's commit on it. + The loop for each to-do is: mark `in_progress`, implement the change, run the relevant local checks, commit, mark `completed`, move on. +- Before committing the first to-do, confirm you're on a feature branch + (`git rev-parse --abbrev-ref HEAD` should not be `main`/`master`); if + it is, create one before staging anything. + - Required pre-commit checks: `./swiftformat --lint` and the matching `tuist test` scheme(s). A red bar means the step is not done β€” fix it before committing, do not stage a broken tree. From de8059a023bc56efb2b430a40f5a15b375e428a1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 11:13:18 -0700 Subject: [PATCH 10/22] Support backfilling a date range in manual day entry Add WhereController.addManualDays(from:through:regions:), which writes a whole inclusive day range in one perform transaction, surface it through WhereModel.setManualDays, and give ManualDayEntryView a Single day / Date range mode with From/Through pickers and a live day count. Covered by new WhereController range tests. Co-authored-by: Cursor --- Where/WhereCore/Sources/WhereController.swift | 34 ++++++ .../Tests/WhereControllerTests.swift | 41 +++++++ Where/WhereUI/Sources/Model/WhereModel.swift | 10 ++ .../Sources/Settings/ManualDayEntryView.swift | 113 +++++++++++++++--- 4 files changed, 184 insertions(+), 14 deletions(-) diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index 7f59e99..347f939 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -69,6 +69,40 @@ public actor WhereController { try await store.perform { try await store.setManualDay(presence) } } + /// Assert `regions` for every calendar day in the inclusive range + /// `start...end` (handy for backfilling a trip). Both bounds are + /// normalized to start-of-day in the aggregator's calendar, and the + /// whole range is written inside a single `perform` transaction so the + /// backfill commits (or rolls back) atomically. A `start` later than + /// `end` is treated as an empty range and writes nothing. + public func addManualDays( + from start: Date, + through end: Date, + regions: Set, + ) async throws { + let calendar = aggregator.calendar + let last = calendar.startOfDay(for: end) + // Enumerate the day keys up front into an immutable array so the + // `@Sendable` transaction body captures a `let`, not a mutable + // cursor, across the concurrency boundary. + let dayKeys: [Date] = { + var keys: [Date] = [] + var cursor = calendar.startOfDay(for: start) + while cursor <= last { + keys.append(cursor) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = calendar.startOfDay(for: next) + } + return keys + }() + guard !dayKeys.isEmpty else { return } + try await store.perform { + for day in dayKeys { + try await store.setManualDay(DayPresence(date: day, regions: regions)) + } + } + } + // MARK: - Evidence public func addEvidence(_ evidence: Evidence, blob: Data? = nil) async throws { diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index a5083c3..b699def 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -76,6 +76,47 @@ struct WhereControllerTests { #expect(report.days.first?.regions == [.newYork]) } + @Test func addManualDaysBackfillsEveryDayInRange() async throws { + let (controller, _, _) = try Self.makeController() + try await controller.addManualDays( + from: iso("2026-02-10T09:00:00-08:00"), + through: iso("2026-02-14T20:00:00-08:00"), + regions: [.newYork], + ) + + let report = try await controller.yearReport(for: 2026) + // Feb 10–14 inclusive is five days, each attributed to New York. + #expect(report.days.count == 5) + #expect(report.totals == [.newYork: 5]) + #expect(report.days.allSatisfy { $0.regions == [.newYork] }) + } + + @Test func addManualDaysWithStartAfterEndWritesNothing() async throws { + let (controller, _, _) = try Self.makeController() + try await controller.addManualDays( + from: iso("2026-02-14T00:00:00-08:00"), + through: iso("2026-02-10T00:00:00-08:00"), + regions: [.california], + ) + + let report = try await controller.yearReport(for: 2026) + #expect(report.days.isEmpty) + #expect(report.totals.isEmpty) + } + + @Test func addManualDaysSameStartAndEndWritesOneDay() async throws { + let (controller, _, _) = try Self.makeController() + try await controller.addManualDays( + from: iso("2026-02-10T06:00:00-08:00"), + through: iso("2026-02-10T23:00:00-08:00"), + regions: [.california], + ) + + let report = try await controller.yearReport(for: 2026) + #expect(report.days.count == 1) + #expect(report.totals == [.california: 1]) + } + @Test func clearYearWipesAndReportsEmpty() async throws { let (controller, _, _) = try Self.makeController() try await controller.ingest(LocationSample( diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index f2a4f2a..a0b24f4 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -109,6 +109,16 @@ public final class WhereModel { } } + public func setManualDays(from start: Date, through end: Date, regions: Set) async { + guard let controller else { return } + do { + try await controller.addManualDays(from: start, through: end, regions: regions) + await refresh() + } catch { + loadState = .failed(error.localizedDescription) + } + } + public func requestPermission() async { guard let controller else { return } do { diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 96f2b59..6df64ee 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -1,28 +1,66 @@ import SwiftUI import WhereCore -/// Retroactively assert which regions a single calendar day belongs to. This -/// overrides any prior manual entry for that day and unions with whatever GPS -/// recorded (see `WhereController.addManualDay`). +/// Retroactively assert which regions a calendar day β€” or a whole range of +/// days β€” belongs to. This overrides any prior manual entry for those days +/// and unions with whatever GPS recorded (see +/// `WhereController.addManualDay` / `addManualDays`). struct ManualDayEntryView: View { @Environment(WhereModel.self) private var model @Environment(\.dismiss) private var dismiss - @State private var date = Date() + private enum EntryMode: Hashable, CaseIterable, Identifiable { + case singleDay + case range + + var id: Self { + self + } + + var title: String { + switch self { + case .singleDay: "Single day" + case .range: "Date range" + } + } + } + + @State private var mode: EntryMode = .singleDay + @State private var startDate = Date() + @State private var endDate = Date() @State private var selectedRegions: Set = [] @State private var isSaving = false + private var dayCount: Int { + let calendar = Calendar.current + let start = calendar.startOfDay(for: startDate) + let end = calendar.startOfDay(for: endDate) + let span = calendar.dateComponents([.day], from: start, to: end).day ?? 0 + return max(0, span) + 1 + } + + private var canSave: Bool { + guard !selectedRegions.isEmpty, !isSaving else { return false } + if mode == .range { + return endDate >= startDate + } + return true + } + var body: some View { Form { Section { - DatePicker( - "Day", - selection: $date, - in: ...Date(), - displayedComponents: .date, - ) + Picker("Entry", selection: $mode) { + ForEach(EntryMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("where_manual_mode") + + datePickers } footer: { - Text("Time travel: tell Where where you really were.") + Text(dateFooter) } Section { @@ -38,19 +76,57 @@ struct ManualDayEntryView: View { } header: { Text("Regions") } footer: { - Text("Saving replaces any manual regions you previously set for this day.") + Text("Saving replaces any manual regions you previously set for those days.") } } .navigationTitle("Log a Day") .navigationBarTitleDisplayMode(.inline) + .onChange(of: startDate) { _, newValue in + if endDate < newValue { endDate = newValue } + } .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } - .disabled(selectedRegions.isEmpty || isSaving) + .disabled(!canSave) } } } + @ViewBuilder + private var datePickers: some View { + switch mode { + case .singleDay: + DatePicker( + "Day", + selection: $startDate, + in: ...Date(), + displayedComponents: .date, + ) + case .range: + DatePicker( + "From", + selection: $startDate, + in: ...Date(), + displayedComponents: .date, + ) + DatePicker( + "Through", + selection: $endDate, + in: startDate ... Date(), + displayedComponents: .date, + ) + } + } + + private var dateFooter: String { + switch mode { + case .singleDay: + "Time travel: tell Where where you really were." + case .range: + "Backfilling \(dayCount) \(dayCount == 1 ? "day" : "days")." + } + } + private func binding(for region: Region) -> Binding { Binding( get: { selectedRegions.contains(region) }, @@ -67,7 +143,16 @@ struct ManualDayEntryView: View { private func save() { isSaving = true Task { - await model.setManualDay(date: date, regions: selectedRegions) + switch mode { + case .singleDay: + await model.setManualDay(date: startDate, regions: selectedRegions) + case .range: + await model.setManualDays( + from: startDate, + through: endDate, + regions: selectedRegions, + ) + } dismiss() } } From ec3073fa4a5100bfd23441b53772b27f8d56e4ad Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 11:21:32 -0700 Subject: [PATCH 11/22] Add a stays timeline to the Primary tab Introduce PresenceTimeline, which folds a YearReport's DayPresence into maximal consecutive RegionStint runs (e.g. California Jan 1 - Feb 3, then New York Feb 3 - Mar 10, sharing the transition day). Surface it from a new navbar button on PrimaryView that presents PresenceTimelineView: a chronological list of region / date-range / duration rows. Covered by PresenceTimeline unit tests plus a hosted smoke test. Co-authored-by: Cursor --- .../Sources/Model/PresenceTimeline.swift | 94 ++++++++++++++++ .../Primary/PresenceTimelineView.swift | 101 ++++++++++++++++++ .../WhereUI/Sources/Primary/PrimaryView.swift | 14 +++ .../WhereUI/Tests/PresenceTimelineTests.swift | 76 +++++++++++++ Where/WhereUI/Tests/ScreenHostingTests.swift | 9 ++ 5 files changed, 294 insertions(+) create mode 100644 Where/WhereUI/Sources/Model/PresenceTimeline.swift create mode 100644 Where/WhereUI/Sources/Primary/PresenceTimelineView.swift create mode 100644 Where/WhereUI/Tests/PresenceTimelineTests.swift diff --git a/Where/WhereUI/Sources/Model/PresenceTimeline.swift b/Where/WhereUI/Sources/Model/PresenceTimeline.swift new file mode 100644 index 0000000..0d62344 --- /dev/null +++ b/Where/WhereUI/Sources/Model/PresenceTimeline.swift @@ -0,0 +1,94 @@ +import Foundation +import WhereCore + +/// A maximal run of consecutive calendar days the user was present in one +/// region β€” e.g. "California, Jan 1 – Feb 3". A transition day that belongs to +/// two regions ends one stint and starts the next, so adjacent stints can +/// share an endpoint date. +public struct RegionStint: Hashable, Sendable, Identifiable { + public let region: Region + public let start: Date + public let end: Date + public let dayCount: Int + + public var id: String { + "\(region.rawValue)@\(start.timeIntervalSinceReferenceDate)" + } + + public init(region: Region, start: Date, end: Date, dayCount: Int) { + self.region = region + self.start = start + self.end = end + self.dayCount = dayCount + } +} + +/// Builds a chronological timeline of `RegionStint`s from a `YearReport`. This +/// is presentation logic derived from `DayPresence`, so it lives in `WhereUI` +/// alongside the views that render it. +public enum PresenceTimeline { + /// Group each region's present days into maximal consecutive runs, then + /// flatten and sort by start date (ties broken by end date, then by + /// `Region.allCases` order). `report.days` is already sorted ascending and + /// has one entry per calendar day, so each region's dates are unique. + public static func stints( + from report: YearReport, + calendar: Calendar = .current, + ) -> [RegionStint] { + var datesByRegion: [Region: [Date]] = [:] + for day in report.days { + for region in day.regions { + datesByRegion[region, default: []].append(day.date) + } + } + + var stints: [RegionStint] = [] + for (region, dates) in datesByRegion { + let sorted = dates.sorted() + guard var runStart = sorted.first else { continue } + var previous = runStart + var count = 1 + for date in sorted.dropFirst() { + if isConsecutive(previous, date, calendar: calendar) { + previous = date + count += 1 + } else { + stints.append(RegionStint( + region: region, + start: runStart, + end: previous, + dayCount: count, + )) + runStart = date + previous = date + count = 1 + } + } + stints.append(RegionStint( + region: region, + start: runStart, + end: previous, + dayCount: count, + )) + } + + return stints.sorted { lhs, rhs in + if lhs.start != rhs.start { return lhs.start < rhs.start } + if lhs.end != rhs.end { return lhs.end < rhs.end } + return regionOrder(lhs.region) < regionOrder(rhs.region) + } + } + + private static func isConsecutive( + _ previous: Date, + _ candidate: Date, + calendar: Calendar, + ) -> Bool { + guard let next = calendar.date(byAdding: .day, value: 1, to: previous) else { return false } + return calendar.isDate(next, inSameDayAs: candidate) + } + + private static func regionOrder(_ region: Region) -> Int { + Region.allCases.firstIndex(of: region) ?? 0 + } +} diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift new file mode 100644 index 0000000..37ad4bd --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -0,0 +1,101 @@ +import SwiftUI +import WhereCore + +/// A chronological list of continuous stays (`RegionStint`s) for the selected +/// year β€” "California, Jan 1 – Feb 3", "New York, Feb 3 – Mar 10", and so on. +/// Presented as a sheet from the Primary tab. +struct PresenceTimelineView: View { + @Environment(WhereModel.self) private var model + @Environment(\.dismiss) private var dismiss + + private var stints: [RegionStint] { + guard let report = model.report else { return [] } + return PresenceTimeline.stints(from: report) + } + + var body: some View { + NavigationStack { + Group { + if stints.isEmpty { + ContentUnavailableView { + Label("No stays yet", systemImage: "calendar.day.timeline.left") + } description: { + Text( + "Once Where has a run of days in a region, your stays will appear here.", + ) + } + } else { + List(stints) { stint in + StintRow(stint: stint) + } + } + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private var navigationTitle: String { + "Timeline Β· \(model.selectedYear)" + } +} + +/// One row in the timeline: region, the date span it covers, and how many +/// consecutive days that was. +private struct StintRow: View { + let stint: RegionStint + + private var style: RegionStyle { + stint.region.style + } + + var body: some View { + HStack(spacing: 12) { + Capsule() + .fill(style.tint.gradient) + .frame(width: 4, height: 34) + + Text(style.emoji) + .font(.title3) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(stint.region.localizedName) + .font(.headline) + Text(dateRange) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(stint.dayCount) \(stint.dayCount == 1 ? "day" : "days")") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(stint.region.localizedName), \(dateRange), \(stint.dayCount) days") + } + + private var dateRange: String { + let format = Date.FormatStyle.dateTime.month(.abbreviated).day() + if Calendar.current.isDate(stint.start, inSameDayAs: stint.end) { + return stint.start.formatted(format) + } + return "\(stint.start.formatted(format)) – \(stint.end.formatted(format))" + } +} + +#if DEBUG + #Preview { + PresenceTimelineView() + .environment(PreviewSupport.loadedModel()) + } +#endif diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 19a01d6..4eca519 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -6,15 +6,29 @@ import WhereCore struct PrimaryView: View { @Environment(WhereModel.self) private var model + @State private var showingTimeline = false + var body: some View { NavigationStack { screen .navigationTitle("Where") .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingTimeline = true + } label: { + Label("Timeline", systemImage: "calendar.day.timeline.left") + } + .accessibilityIdentifier("where_timeline_button") + } ToolbarItem(placement: .topBarTrailing) { YearSelector() } } + .sheet(isPresented: $showingTimeline) { + PresenceTimelineView() + .environment(model) + } } } diff --git a/Where/WhereUI/Tests/PresenceTimelineTests.swift b/Where/WhereUI/Tests/PresenceTimelineTests.swift new file mode 100644 index 0000000..56a24dd --- /dev/null +++ b/Where/WhereUI/Tests/PresenceTimelineTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +import WhereCore +import WhereUI + +struct PresenceTimelineTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + private func day(_ year: Int, _ month: Int, _ dayOfMonth: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: dayOfMonth))! + } + + private func report(_ days: [DayPresence]) -> YearReport { + YearReport(year: 2026, days: days, totals: [:]) + } + + @Test func groupsConsecutiveDaysIntoOneStint() { + let days = (1 ... 3).map { DayPresence(date: day(2026, 1, $0), regions: [.california]) } + let stints = PresenceTimeline.stints(from: report(days), calendar: calendar) + #expect(stints.count == 1) + #expect(stints[0].region == .california) + #expect(stints[0].dayCount == 3) + #expect(calendar.isDate(stints[0].start, inSameDayAs: day(2026, 1, 1))) + #expect(calendar.isDate(stints[0].end, inSameDayAs: day(2026, 1, 3))) + } + + @Test func breaksStintOnAGap() { + let days = [ + DayPresence(date: day(2026, 1, 1), regions: [.california]), + DayPresence(date: day(2026, 1, 2), regions: [.california]), + DayPresence(date: day(2026, 1, 5), regions: [.california]), + ] + let stints = PresenceTimeline.stints(from: report(days), calendar: calendar) + #expect(stints.count == 2) + #expect(stints[0].dayCount == 2) + #expect(stints[1].dayCount == 1) + #expect(calendar.isDate(stints[1].start, inSameDayAs: day(2026, 1, 5))) + } + + @Test func transitionDayIsSharedByBothRegions() { + // CA Jan 1–3, with Jan 3 also New York, then NY Jan 3–5. + let days = [ + DayPresence(date: day(2026, 1, 1), regions: [.california]), + DayPresence(date: day(2026, 1, 2), regions: [.california]), + DayPresence(date: day(2026, 1, 3), regions: [.california, .newYork]), + DayPresence(date: day(2026, 1, 4), regions: [.newYork]), + DayPresence(date: day(2026, 1, 5), regions: [.newYork]), + ] + let stints = PresenceTimeline.stints(from: report(days), calendar: calendar) + #expect(stints.count == 2) + #expect(stints[0].region == .california) + #expect(stints[0].dayCount == 3) + #expect(calendar.isDate(stints[0].end, inSameDayAs: day(2026, 1, 3))) + #expect(stints[1].region == .newYork) + #expect(stints[1].dayCount == 3) + #expect(calendar.isDate(stints[1].start, inSameDayAs: day(2026, 1, 3))) + } + + @Test func sortsChronologicallyAcrossRegions() { + let days = [ + DayPresence(date: day(2026, 3, 1), regions: [.newYork]), + DayPresence(date: day(2026, 1, 1), regions: [.california]), + DayPresence(date: day(2026, 2, 1), regions: [.canada]), + ] + let stints = PresenceTimeline.stints(from: report(days), calendar: calendar) + #expect(stints.map(\.region) == [.california, .canada, .newYork]) + } + + @Test func emptyReportProducesNoStints() { + #expect(PresenceTimeline.stints(from: report([]), calendar: calendar).isEmpty) + } +} diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 17415ea..243d1ca 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -27,4 +27,13 @@ struct ScreenHostingTests { #expect(hosted.view != nil) } } + + @Test func presenceTimelineViewHostsWithData() throws { + let model = PreviewSupport.loadedModel() + try show(UIHostingController(rootView: PresenceTimelineView() + .environment(model))) + { hosted in + #expect(hosted.view != nil) + } + } } From 90026487abe9b5e415b1c246117af0a72cb7abfd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 11:58:03 -0700 Subject: [PATCH 12/22] Extract calendar-day enumeration into a tested Date extension Addresses PR review on addManualDays: move the per-day range enumeration into Date.calendarDays(through:in:) in WhereCore and cover it directly with unit tests, leaving the controller transaction thin. Co-authored-by: Cursor --- .../WhereCore/Sources/Date+CalendarDays.swift | 25 +++++++++ Where/WhereCore/Sources/WhereController.swift | 19 ++----- .../Tests/DateCalendarDaysTests.swift | 52 +++++++++++++++++++ 3 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 Where/WhereCore/Sources/Date+CalendarDays.swift create mode 100644 Where/WhereCore/Tests/DateCalendarDaysTests.swift diff --git a/Where/WhereCore/Sources/Date+CalendarDays.swift b/Where/WhereCore/Sources/Date+CalendarDays.swift new file mode 100644 index 0000000..85d3554 --- /dev/null +++ b/Where/WhereCore/Sources/Date+CalendarDays.swift @@ -0,0 +1,25 @@ +import Foundation + +extension Date { + /// The start-of-day key for every calendar day in the inclusive range + /// `self ... end`, evaluated in `calendar`. Both endpoints are normalized + /// to start-of-day, so a same-day range yields a single element. Returns + /// an empty array when `end` falls before `self`. + /// + /// Used to fan a date range out into per-day keys (e.g. backfilling a trip + /// via `WhereController.addManualDays`). + public func calendarDays(through end: Date, in calendar: Calendar) -> [Date] { + let first = calendar.startOfDay(for: self) + let last = calendar.startOfDay(for: end) + guard first <= last else { return [] } + + var days: [Date] = [] + var cursor = first + while cursor <= last { + days.append(cursor) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = calendar.startOfDay(for: next) + } + return days + } +} diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index 347f939..e43e374 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -80,21 +80,10 @@ public actor WhereController { through end: Date, regions: Set, ) async throws { - let calendar = aggregator.calendar - let last = calendar.startOfDay(for: end) - // Enumerate the day keys up front into an immutable array so the - // `@Sendable` transaction body captures a `let`, not a mutable - // cursor, across the concurrency boundary. - let dayKeys: [Date] = { - var keys: [Date] = [] - var cursor = calendar.startOfDay(for: start) - while cursor <= last { - keys.append(cursor) - guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } - cursor = calendar.startOfDay(for: next) - } - return keys - }() + // `calendarDays` returns an immutable array, so the `@Sendable` + // transaction body captures a `let` rather than a mutable cursor + // across the concurrency boundary. + let dayKeys = start.calendarDays(through: end, in: aggregator.calendar) guard !dayKeys.isEmpty else { return } try await store.perform { for day in dayKeys { diff --git a/Where/WhereCore/Tests/DateCalendarDaysTests.swift b/Where/WhereCore/Tests/DateCalendarDaysTests.swift new file mode 100644 index 0000000..0581d99 --- /dev/null +++ b/Where/WhereCore/Tests/DateCalendarDaysTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +import WhereCore + +struct DateCalendarDaysTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + private func day(_ year: Int, _ month: Int, _ dayOfMonth: Int, hour: Int = 0) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: dayOfMonth, hour: hour))! + } + + @Test func enumeratesEveryDayInRangeInclusive() throws { + let days = day(2026, 2, 10).calendarDays(through: day(2026, 2, 14), in: calendar) + #expect(days.count == 5) + #expect(try calendar.isDate(#require(days.first), inSameDayAs: day(2026, 2, 10))) + #expect(try calendar.isDate(#require(days.last), inSameDayAs: day(2026, 2, 14))) + } + + @Test func normalizesEndpointsToStartOfDay() { + let days = day(2026, 2, 10, hour: 23).calendarDays( + through: day(2026, 2, 11, hour: 1), + in: calendar, + ) + #expect(days.count == 2) + #expect(days.allSatisfy { $0 == calendar.startOfDay(for: $0) }) + } + + @Test func sameDayRangeYieldsOneDay() { + let days = day(2026, 2, 10, hour: 6).calendarDays( + through: day(2026, 2, 10, hour: 20), + in: calendar, + ) + #expect(days.count == 1) + #expect(calendar.isDate(days[0], inSameDayAs: day(2026, 2, 10))) + } + + @Test func endBeforeStartYieldsEmpty() { + let days = day(2026, 2, 14).calendarDays(through: day(2026, 2, 10), in: calendar) + #expect(days.isEmpty) + } + + @Test func crossesMonthBoundary() { + let days = day(2026, 1, 30).calendarDays(through: day(2026, 2, 2), in: calendar) + #expect(days.count == 4) + #expect(calendar.isDate(days[1], inSameDayAs: day(2026, 1, 31))) + #expect(calendar.isDate(days[2], inSameDayAs: day(2026, 2, 1))) + } +} From 0f1499544f493b54837feb4bd6ac259a719db317 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 11:59:05 -0700 Subject: [PATCH 13/22] Gate tracking on permission and clear stale report on year switch Addresses cursor[bot] review: - startTracking() now confirms/requests location permission and only flips isTracking once GPS is actually running, surfacing the Settings alert (and leaving the toggle off) on denial. - select(year:) drops the previous report so views show their loading state instead of rendering last year's data under the new year label. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/WhereModel.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index a0b24f4..f71e899 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -85,6 +85,9 @@ public final class WhereModel { public func select(year: Int) async { guard year != selectedYear else { return } selectedYear = year + // Drop the previous year's report so views fall back to their loading + // state instead of rendering stale data under the new year's label. + report = nil await refresh() } @@ -131,8 +134,21 @@ public final class WhereModel { } } + /// Turn on background tracking. Confirms (and, if needed, requests) + /// location permission first, and only reports `isTracking` once GPS is + /// actually running β€” otherwise the toggle would read "on" while + /// CoreLocation silently produces nothing. On denial the Settings alert is + /// surfaced and tracking stays off. public func startTracking() async { guard let controller else { return } + do { + try await controller.requestLocationPermission() + permissionDenied = false + } catch { + permissionDenied = true + isTracking = false + return + } await controller.startGPS() isTracking = true } From 9cf516c392d6f7380618e67226f2a46f5a6ca595 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 12:00:06 -0700 Subject: [PATCH 14/22] Derive region-card year length from the calendar Addresses PR review: replace the hardcoded 365 with WhereModel.daysInSelectedYear (365/366 from Calendar) so the ambient progress bar scales correctly in leap years. The card default stays 365 for previews only. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/WhereModel.swift | 16 ++++++++++++++++ Where/WhereUI/Sources/Primary/PrimaryView.swift | 6 +++++- .../Sources/Primary/RegionSummaryCard.swift | 4 +++- .../Sources/Secondary/SecondaryView.swift | 1 + 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index f71e899..7b789fd 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -41,6 +41,22 @@ public final class WhereModel { report?.days.count ?? 0 } + /// Number of calendar days in the selected year (365, or 366 in a leap + /// year). Region cards scale their ambient progress bar against this rather + /// than a hardcoded 365. + public var daysInSelectedYear: Int { + let calendar = Calendar.current + guard + let midYear = calendar.date(from: DateComponents( + year: selectedYear, + month: 6, + day: 15, + )), + let range = calendar.range(of: .day, in: .year, for: midYear) + else { return 365 } + return range.count + } + public static var currentYear: Int { Calendar.current.component(.year, from: Date()) } diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 4eca519..7dbcaab 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -63,7 +63,11 @@ struct PrimaryView: View { Array(model.ranking.primary.enumerated()), id: \.element.id, ) { index, item in - RegionSummaryCard(regionDays: item, caption: caption(forRank: index)) + RegionSummaryCard( + regionDays: item, + caption: caption(forRank: index), + yearLength: model.daysInSelectedYear, + ) } } } diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index b60915f..342eac8 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -8,7 +8,9 @@ struct RegionSummaryCard: View { var caption: String? var compact = false - /// Days in a typical year; the ambient bar is drawn as a fraction of this. + /// Calendar days in the year being summarized; the ambient bar is drawn as + /// a fraction of this. Callers pass the selected year's real length + /// (`WhereModel.daysInSelectedYear`); the default is only for previews. var yearLength = 365 private var style: RegionStyle { diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 7683ea2..9a2fc65 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -54,6 +54,7 @@ struct SecondaryView: View { regionDays: item, caption: caption(for: item), compact: true, + yearLength: model.daysInSelectedYear, ) } } From a8da4810878ca26a5ba56170f730b1ce87797fcd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 12:01:57 -0700 Subject: [PATCH 15/22] Centralize WhereUI layout literals in UIConstants Addresses PR review: introduce UIConstants (Spacings/Padding/ CornerRadius/Size) and replace the hardcoded spacing, padding, corner-radius, and element-size numbers across the region cards, Primary, Elsewhere, and the timeline rows. Co-authored-by: Cursor --- .../Primary/PresenceTimelineView.swift | 13 ++++--- .../WhereUI/Sources/Primary/PrimaryView.swift | 8 ++-- .../Sources/Primary/RegionSummaryCard.swift | 29 +++++++++----- .../Sources/Secondary/SecondaryView.swift | 6 +-- .../WhereUI/Sources/Shared/UIConstants.swift | 38 +++++++++++++++++++ 5 files changed, 73 insertions(+), 21 deletions(-) create mode 100644 Where/WhereUI/Sources/Shared/UIConstants.swift diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 37ad4bd..e0233f5 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -55,16 +55,19 @@ private struct StintRow: View { } var body: some View { - HStack(spacing: 12) { + HStack(spacing: UIConstants.Spacings.large) { Capsule() .fill(style.tint.gradient) - .frame(width: 4, height: 34) + .frame( + width: UIConstants.Size.timelineAccentWidth, + height: UIConstants.Size.timelineAccentHeight, + ) Text(style.emoji) .font(.title3) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { Text(stint.region.localizedName) .font(.headline) Text(dateRange) @@ -72,14 +75,14 @@ private struct StintRow: View { .foregroundStyle(.secondary) } - Spacer(minLength: 8) + Spacer(minLength: UIConstants.Spacings.medium) Text("\(stint.dayCount) \(stint.dayCount == 1 ? "day" : "days")") .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .monospacedDigit() } - .padding(.vertical, 4) + .padding(.vertical, UIConstants.Spacings.xSmall) .accessibilityElement(children: .combine) .accessibilityLabel("\(stint.region.localizedName), \(dateRange), \(stint.dayCount) days") } diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 7dbcaab..7dba96b 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -55,10 +55,10 @@ struct PrimaryView: View { private var content: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxxLarge) { header - GlassEffectContainer(spacing: 16) { - VStack(spacing: 16) { + GlassEffectContainer(spacing: UIConstants.Spacings.xxLarge) { + VStack(spacing: UIConstants.Spacings.xxLarge) { ForEach( Array(model.ranking.primary.enumerated()), id: \.element.id, @@ -78,7 +78,7 @@ struct PrimaryView: View { } private var header: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xSmall) { Text(verbatim: "Where have you been in \(model.selectedYear)?") .font(.largeTitle.bold()) Text(verbatim: "\(model.trackedDayCount) days on the map so far") diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 342eac8..656b1ba 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -23,13 +23,16 @@ struct RegionSummaryCard: View { } var body: some View { - VStack(alignment: .leading, spacing: compact ? 10 : 16) { - HStack(spacing: 12) { + VStack( + alignment: .leading, + spacing: compact ? UIConstants.Spacings.regular : UIConstants.Spacings.xxLarge, + ) { + HStack(spacing: UIConstants.Spacings.large) { Text(style.emoji) .font(compact ? .title2 : .largeTitle) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { Text(regionDays.region.localizedName) .font(compact ? .headline : .title3.weight(.semibold)) if let caption { @@ -47,12 +50,16 @@ struct RegionSummaryCard: View { .accessibilityHidden(true) } - HStack(alignment: .firstTextBaseline, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: UIConstants.Spacings.small) { Text(regionDays.days, format: .number) .font( compact ? .system(.title, design: .rounded, weight: .bold) - : .system(size: 46, weight: .bold, design: .rounded), + : .system( + size: UIConstants.Size.heroNumberFontSize, + weight: .bold, + design: .rounded, + ), ) .contentTransition(.numericText()) .foregroundStyle(style.tint) @@ -63,7 +70,7 @@ struct RegionSummaryCard: View { Capsule() .fill(.quaternary) - .frame(height: 6) + .frame(height: UIConstants.Size.progressBarHeight) .overlay(alignment: .leading) { GeometryReader { proxy in Capsule() @@ -71,14 +78,18 @@ struct RegionSummaryCard: View { .frame(width: proxy.size.width * fraction) } } - .frame(height: 6) + .frame(height: UIConstants.Size.progressBarHeight) .accessibilityHidden(true) } - .padding(compact ? 16 : 22) + .padding(compact ? UIConstants.Padding.compactCard : UIConstants.Padding.card) .frame(maxWidth: .infinity, alignment: .leading) .glassEffect( .regular.tint(style.tint.opacity(0.18)), - in: RoundedRectangle(cornerRadius: compact ? 22 : 28, style: .continuous), + in: RoundedRectangle( + cornerRadius: compact ? UIConstants.CornerRadius.compactCard : UIConstants + .CornerRadius.card, + style: .continuous, + ), ) .accessibilityElement(children: .combine) .accessibilityLabel( diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 9a2fc65..1bbbbd2 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -41,14 +41,14 @@ struct SecondaryView: View { private var content: some View { ScrollView { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xLarge) { Text(verbatim: "Everywhere else you turned up in \(model.selectedYear).") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - GlassEffectContainer(spacing: 12) { - VStack(spacing: 12) { + GlassEffectContainer(spacing: UIConstants.Spacings.large) { + VStack(spacing: UIConstants.Spacings.large) { ForEach(model.ranking.secondary) { item in RegionSummaryCard( regionDays: item, diff --git a/Where/WhereUI/Sources/Shared/UIConstants.swift b/Where/WhereUI/Sources/Shared/UIConstants.swift new file mode 100644 index 0000000..457d841 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/UIConstants.swift @@ -0,0 +1,38 @@ +import CoreGraphics + +/// Centralized layout constants for WhereUI so views don't sprinkle magic +/// numbers for spacing, padding, corner radii, and one-off element sizes. +enum UIConstants { + /// Generic spacing scale, in points. + enum Spacings { + static let xxSmall: CGFloat = 2 + static let xSmall: CGFloat = 4 + static let small: CGFloat = 6 + static let medium: CGFloat = 8 + static let regular: CGFloat = 10 + static let large: CGFloat = 12 + static let xLarge: CGFloat = 14 + static let xxLarge: CGFloat = 16 + static let xxxLarge: CGFloat = 20 + } + + /// Padding inside container surfaces such as the region cards. + enum Padding { + static let compactCard: CGFloat = 16 + static let card: CGFloat = 22 + } + + /// Corner radii for Liquid Glass surfaces. + enum CornerRadius { + static let compactCard: CGFloat = 22 + static let card: CGFloat = 28 + } + + /// One-off element sizes that aren't part of the spacing scale. + enum Size { + static let progressBarHeight: CGFloat = 6 + static let timelineAccentWidth: CGFloat = 4 + static let timelineAccentHeight: CGFloat = 34 + static let heroNumberFontSize: CGFloat = 46 + } +} From 492b4007ff3aee193c8f40d15c4ffe5fd95186c7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 12:09:34 -0700 Subject: [PATCH 16/22] Route all WhereUI strings through a string catalog Addresses PR review: add Resources/Localizable.xcstrings to WhereUI (wired via Package.swift resources) and a Strings helper that resolves every user-facing string through bundle: .module. Counts use catalog plural variations; years are formatted with a grouping-free number style ("2026", not "2,026") to address the formatter feedback on Elsewhere. Adds StringsTests to lock in the wiring. Co-authored-by: Cursor --- Package.swift | 3 + .../Primary/PresenceTimelineView.swift | 24 +- .../WhereUI/Sources/Primary/PrimaryView.swift | 27 +- .../Sources/Primary/RegionSummaryCard.swift | 7 +- .../Sources/Resources/Localizable.xcstrings | 669 ++++++++++++++++++ Where/WhereUI/Sources/RootView.swift | 6 +- .../Sources/Secondary/SecondaryView.swift | 16 +- .../Sources/Settings/ManualDayEntryView.swift | 24 +- .../Sources/Settings/SettingsView.swift | 40 +- Where/WhereUI/Sources/Shared/Strings.swift | 305 ++++++++ .../WhereUI/Sources/Shared/YearSelector.swift | 11 +- Where/WhereUI/Tests/StringsTests.swift | 34 + 12 files changed, 1088 insertions(+), 78 deletions(-) create mode 100644 Where/WhereUI/Sources/Resources/Localizable.xcstrings create mode 100644 Where/WhereUI/Sources/Shared/Strings.swift create mode 100644 Where/WhereUI/Tests/StringsTests.swift diff --git a/Package.swift b/Package.swift index 081b3d8..99280ad 100644 --- a/Package.swift +++ b/Package.swift @@ -34,6 +34,9 @@ let package = Package( .target(name: "WhereCore"), ], path: "Where/WhereUI/Sources", + resources: [ + .process("Resources"), + ], ), .target( name: "WhereTesting", diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index e0233f5..cfa4db7 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -18,11 +18,9 @@ struct PresenceTimelineView: View { Group { if stints.isEmpty { ContentUnavailableView { - Label("No stays yet", systemImage: "calendar.day.timeline.left") + Label(Strings.timelineEmptyTitle, systemImage: "calendar.day.timeline.left") } description: { - Text( - "Once Where has a run of days in a region, your stays will appear here.", - ) + Text(Strings.timelineEmptyDescription) } } else { List(stints) { stint in @@ -30,19 +28,15 @@ struct PresenceTimelineView: View { } } } - .navigationTitle(navigationTitle) + .navigationTitle(Strings.timelineTitle(year: model.selectedYear)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } + Button(Strings.timelineDone) { dismiss() } } } } } - - private var navigationTitle: String { - "Timeline Β· \(model.selectedYear)" - } } /// One row in the timeline: region, the date span it covers, and how many @@ -77,14 +71,20 @@ private struct StintRow: View { Spacer(minLength: UIConstants.Spacings.medium) - Text("\(stint.dayCount) \(stint.dayCount == 1 ? "day" : "days")") + Text(Strings.dayCount(stint.dayCount)) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .monospacedDigit() } .padding(.vertical, UIConstants.Spacings.xSmall) .accessibilityElement(children: .combine) - .accessibilityLabel("\(stint.region.localizedName), \(dateRange), \(stint.dayCount) days") + .accessibilityLabel( + Strings.timelineRowAccessibility( + region: stint.region.localizedName, + range: dateRange, + days: stint.dayCount, + ), + ) } private var dateRange: String { diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 7dba96b..fd5545b 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -11,13 +11,16 @@ struct PrimaryView: View { var body: some View { NavigationStack { screen - .navigationTitle("Where") + .navigationTitle(Strings.primaryTitle) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { showingTimeline = true } label: { - Label("Timeline", systemImage: "calendar.day.timeline.left") + Label( + Strings.primaryTimeline, + systemImage: "calendar.day.timeline.left", + ) } .accessibilityIdentifier("where_timeline_button") } @@ -36,11 +39,11 @@ struct PrimaryView: View { private var screen: some View { switch model.loadState { case .loading where model.report == nil: - ProgressView("Charting your year…") + ProgressView(Strings.primaryLoading) .frame(maxWidth: .infinity, maxHeight: .infinity) case let .failed(message): ContentUnavailableView { - Label("Couldn't load your year", systemImage: "exclamationmark.icloud") + Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { Text(message) } @@ -79,9 +82,9 @@ struct PrimaryView: View { private var header: some View { VStack(alignment: .leading, spacing: UIConstants.Spacings.xSmall) { - Text(verbatim: "Where have you been in \(model.selectedYear)?") + Text(Strings.primaryHeaderTitle(year: model.selectedYear)) .font(.largeTitle.bold()) - Text(verbatim: "\(model.trackedDayCount) days on the map so far") + Text(Strings.primaryHeaderSubtitle(count: model.trackedDayCount)) .font(.subheadline) .foregroundStyle(.secondary) } @@ -90,21 +93,17 @@ struct PrimaryView: View { private var emptyState: some View { ContentUnavailableView { - Label(noTravelsTitle, systemImage: "map") + Label(Strings.primaryEmptyTitle(year: model.selectedYear), systemImage: "map") } description: { - Text("Turn on tracking or add a day in Settings and your top spots will land here.") + Text(Strings.primaryEmptyDescription) } } - private var noTravelsTitle: String { - "No travels logged for \(model.selectedYear)" - } - /// Playful rank labels for the top regions. private func caption(forRank rank: Int) -> String? { switch rank { - case 0: "Home base" - case 1: "Second home" + case 0: Strings.primaryCaptionHomeBase + case 1: Strings.primaryCaptionSecondHome default: nil } } diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 656b1ba..846673b 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -63,7 +63,7 @@ struct RegionSummaryCard: View { ) .contentTransition(.numericText()) .foregroundStyle(style.tint) - Text(regionDays.days == 1 ? "day" : "days") + Text(Strings.dayUnit(regionDays.days)) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) } @@ -93,7 +93,10 @@ struct RegionSummaryCard: View { ) .accessibilityElement(children: .combine) .accessibilityLabel( - "\(regionDays.region.localizedName): \(regionDays.days) days", + Strings.regionDaysAccessibility( + region: regionDays.region.localizedName, + days: regionDays.days, + ), ) } } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings new file mode 100644 index 0000000..7d5963b --- /dev/null +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,669 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "common.day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "day" + } + } + } + }, + "common.dayCount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days" + } + } + } + } + } + } + }, + "common.days" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "days" + } + } + } + }, + "common.loadError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load your year" + } + } + } + }, + "common.regionDays.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "manual.day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Day" + } + } + } + }, + "manual.entry.pickerLabel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entry" + } + } + } + }, + "manual.from" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "From" + } + } + } + }, + "manual.mode.range" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date range" + } + } + } + }, + "manual.mode.singleDay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Single day" + } + } + } + }, + "manual.range.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backfilling %lld day." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backfilling %lld days." + } + } + } + } + } + } + }, + "manual.regions.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saving replaces any manual regions you previously set for those days." + } + } + } + }, + "manual.regions.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regions" + } + } + } + }, + "manual.save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "manual.singleDay.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time travel: tell Where where you really were." + } + } + } + }, + "manual.through" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Through" + } + } + } + }, + "manual.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log a Day" + } + } + } + }, + "primary.caption.homeBase" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home base" + } + } + } + }, + "primary.caption.secondHome" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Second home" + } + } + } + }, + "primary.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn on tracking or add a day in Settings and your top spots will land here." + } + } + } + }, + "primary.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No travels logged for %@" + } + } + } + }, + "primary.header.subtitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day on the map so far" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days on the map so far" + } + } + } + } + } + } + }, + "primary.header.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where have you been in %@?" + } + } + } + }, + "primary.loading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charting your year…" + } + } + } + }, + "primary.timeline" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeline" + } + } + } + }, + "primary.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where" + } + } + } + }, + "secondary.caption.passingThrough" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Just passing through" + } + } + } + }, + "secondary.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spend a day outside your top spots β€” or log a trip in Settings β€” and it'll appear here." + } + } + } + }, + "secondary.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nowhere else logged" + } + } + } + }, + "secondary.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Everywhere else you turned up in %@." + } + } + } + }, + "secondary.loading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retracing your steps…" + } + } + } + }, + "secondary.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elsewhere" + } + } + } + }, + "settings.data.cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "settings.data.confirmMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This removes every sample, manual day, and piece of evidence in %@. It can't be undone." + } + } + } + }, + "settings.data.erase" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erase %@ data" + } + } + } + }, + "settings.data.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acts on the year selected on the Primary tab (%@)." + } + } + } + }, + "settings.data.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + } + } + }, + "settings.location.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience." + } + } + } + }, + "settings.location.grant" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant location access" + } + } + } + }, + "settings.location.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location" + } + } + } + }, + "settings.location.toggle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track in the background" + } + } + } + }, + "settings.manual.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backfill a trip the GPS missed, or correct a day by hand." + } + } + } + }, + "settings.manual.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manual entry" + } + } + } + }, + "settings.manual.link" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log or override a day" + } + } + } + }, + "settings.permissionAlert.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where needs Always location access to log which region you're in. You can grant it in the Settings app." + } + } + } + }, + "settings.permissionAlert.notNow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not now" + } + } + } + }, + "settings.permissionAlert.openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, + "settings.permissionAlert.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location access needed" + } + } + } + }, + "settings.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "tab.elsewhere" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elsewhere" + } + } + } + }, + "tab.primary" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primary" + } + } + } + }, + "tab.settings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "timeline.done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "timeline.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Once Where has a run of days in a region, your stays will appear here." + } + } + } + }, + "timeline.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No stays yet" + } + } + } + }, + "timeline.row.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@, %3$@" + } + } + } + }, + "timeline.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeline Β· %@" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index 482946a..d674ced 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -11,15 +11,15 @@ public struct RootView: View { public var body: some View { TabView { - Tab("Primary", systemImage: "star.fill") { + Tab(Strings.tabPrimary, systemImage: "star.fill") { PrimaryView() } - Tab("Elsewhere", systemImage: "globe.americas.fill") { + Tab(Strings.tabElsewhere, systemImage: "globe.americas.fill") { SecondaryView() } - Tab("Settings", systemImage: "gearshape.fill") { + Tab(Strings.tabSettings, systemImage: "gearshape.fill") { SettingsView() } } diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index 1bbbbd2..0496e24 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -9,7 +9,7 @@ struct SecondaryView: View { var body: some View { NavigationStack { screen - .navigationTitle("Elsewhere") + .navigationTitle(Strings.secondaryTitle) .toolbar { ToolbarItem(placement: .topBarTrailing) { YearSelector() @@ -22,11 +22,11 @@ struct SecondaryView: View { private var screen: some View { switch model.loadState { case .loading where model.report == nil: - ProgressView("Retracing your steps…") + ProgressView(Strings.secondaryLoading) .frame(maxWidth: .infinity, maxHeight: .infinity) case let .failed(message): ContentUnavailableView { - Label("Couldn't load your year", systemImage: "exclamationmark.icloud") + Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") } description: { Text(message) } @@ -42,7 +42,7 @@ struct SecondaryView: View { private var content: some View { ScrollView { VStack(alignment: .leading, spacing: UIConstants.Spacings.xLarge) { - Text(verbatim: "Everywhere else you turned up in \(model.selectedYear).") + Text(Strings.secondaryHeader(year: model.selectedYear)) .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -66,17 +66,15 @@ struct SecondaryView: View { private var emptyState: some View { ContentUnavailableView { - Label("Nowhere else logged", systemImage: "globe.americas") + Label(Strings.secondaryEmptyTitle, systemImage: "globe.americas") } description: { - Text( - "Spend a day outside your top spots β€” or log a trip in Settings β€” and it'll appear here.", - ) + Text(Strings.secondaryEmptyDescription) } } /// Light whimsy for the briefest stays. private func caption(for item: RegionDays) -> String? { - item.days <= 3 ? "Just passing through" : nil + item.days <= 3 ? Strings.secondaryCaptionPassingThrough : nil } } diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 6df64ee..77fce36 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -19,8 +19,8 @@ struct ManualDayEntryView: View { var title: String { switch self { - case .singleDay: "Single day" - case .range: "Date range" + case .singleDay: Strings.manualModeSingleDay + case .range: Strings.manualModeRange } } } @@ -50,7 +50,7 @@ struct ManualDayEntryView: View { var body: some View { Form { Section { - Picker("Entry", selection: $mode) { + Picker(Strings.manualEntryPickerLabel, selection: $mode) { ForEach(EntryMode.allCases) { mode in Text(mode.title).tag(mode) } @@ -74,19 +74,19 @@ struct ManualDayEntryView: View { } } } header: { - Text("Regions") + Text(Strings.manualRegionsHeader) } footer: { - Text("Saving replaces any manual regions you previously set for those days.") + Text(Strings.manualRegionsFooter) } } - .navigationTitle("Log a Day") + .navigationTitle(Strings.manualTitle) .navigationBarTitleDisplayMode(.inline) .onChange(of: startDate) { _, newValue in if endDate < newValue { endDate = newValue } } .toolbar { ToolbarItem(placement: .confirmationAction) { - Button("Save") { save() } + Button(Strings.manualSave) { save() } .disabled(!canSave) } } @@ -97,20 +97,20 @@ struct ManualDayEntryView: View { switch mode { case .singleDay: DatePicker( - "Day", + Strings.manualDay, selection: $startDate, in: ...Date(), displayedComponents: .date, ) case .range: DatePicker( - "From", + Strings.manualFrom, selection: $startDate, in: ...Date(), displayedComponents: .date, ) DatePicker( - "Through", + Strings.manualThrough, selection: $endDate, in: startDate ... Date(), displayedComponents: .date, @@ -121,9 +121,9 @@ struct ManualDayEntryView: View { private var dateFooter: String { switch mode { case .singleDay: - "Time travel: tell Where where you really were." + Strings.manualSingleDayFooter case .range: - "Backfilling \(dayCount) \(dayCount == 1 ? "day" : "days")." + Strings.manualRangeFooter(count: dayCount) } } diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index bee971f..0a4ad80 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -19,14 +19,12 @@ struct SettingsView: View { manualEntrySection dataSection } - .navigationTitle("Settings") - .alert("Location access needed", isPresented: $model.permissionDenied) { - Button("Open Settings") { openSystemSettings() } - Button("Not now", role: .cancel) {} + .navigationTitle(Strings.settingsTitle) + .alert(Strings.settingsPermissionAlertTitle, isPresented: $model.permissionDenied) { + Button(Strings.settingsPermissionAlertOpenSettings) { openSystemSettings() } + Button(Strings.settingsPermissionAlertNotNow, role: .cancel) {} } message: { - Text( - "Where needs Always location access to log which region you're in. You can grant it in the Settings app.", - ) + Text(Strings.settingsPermissionAlertMessage) } } } @@ -34,19 +32,17 @@ struct SettingsView: View { private var trackingSection: some View { Section { Toggle(isOn: trackingBinding) { - Label("Track in the background", systemImage: "location.fill") + Label(Strings.settingsLocationToggle, systemImage: "location.fill") } Button { Task { await model.requestPermission() } } label: { - Label("Grant location access", systemImage: "location.magnifyingglass") + Label(Strings.settingsLocationGrant, systemImage: "location.magnifyingglass") } } header: { - Text("Location") + Text(Strings.settingsLocationHeader) } footer: { - Text( - "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience.", - ) + Text(Strings.settingsLocationFooter) } } @@ -55,12 +51,12 @@ struct SettingsView: View { NavigationLink { ManualDayEntryView() } label: { - Label("Log or override a day", systemImage: "calendar.badge.plus") + Label(Strings.settingsManualLink, systemImage: "calendar.badge.plus") } } header: { - Text("Manual entry") + Text(Strings.settingsManualHeader) } footer: { - Text("Backfill a trip the GPS missed, or correct a day by hand.") + Text(Strings.settingsManualFooter) } } @@ -79,21 +75,19 @@ struct SettingsView: View { Button(eraseTitle, role: .destructive) { Task { await model.clearSelectedYear() } } - Button("Cancel", role: .cancel) {} + Button(Strings.settingsDataCancel, role: .cancel) {} } message: { - Text( - verbatim: "This removes every sample, manual day, and piece of evidence in \(model.selectedYear). It can't be undone.", - ) + Text(Strings.settingsDataConfirmMessage(year: model.selectedYear)) } } header: { - Text("Data") + Text(Strings.settingsDataHeader) } footer: { - Text(verbatim: "Acts on the year selected on the Primary tab (\(model.selectedYear)).") + Text(Strings.settingsDataFooter(year: model.selectedYear)) } } private var eraseTitle: String { - "Erase \(model.selectedYear) data" + Strings.settingsDataErase(year: model.selectedYear) } private var trackingBinding: Binding { diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift new file mode 100644 index 0000000..37e69d7 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -0,0 +1,305 @@ +import Foundation + +/// Localized, catalog-backed strings for WhereUI. +/// +/// Every user-facing string in the module is funneled through here so the +/// views stay free of literals and so lookups resolve against the module's +/// `Resources/Localizable.xcstrings` (`bundle: .module`). Counts use the +/// catalog's plural variations; years are formatted with a grouping-free +/// number style so they read "2026", never "2,026". +enum Strings { + // MARK: Tabs + + static var tabPrimary: String { + localized("tab.primary") + } + + static var tabElsewhere: String { + localized("tab.elsewhere") + } + + static var tabSettings: String { + localized("tab.settings") + } + + // MARK: Shared + + static var loadErrorTitle: String { + localized("common.loadError.title") + } + + /// "1 day" / "5 days" β€” with the count rendered. + static func dayCount(_ count: Int) -> String { + String(localized: "common.dayCount", defaultValue: "\(count) days", bundle: .module) + } + + /// "day" / "days" β€” the bare unit, when the count is shown separately. + static func dayUnit(_ count: Int) -> String { + count == 1 ? localized("common.day") : localized("common.days") + } + + static func regionDaysAccessibility(region: String, days: Int) -> String { + String( + localized: "common.regionDays.accessibility", + defaultValue: "\(region): \(dayCount(days))", + bundle: .module, + ) + } + + // MARK: Primary + + static var primaryTitle: String { + localized("primary.title") + } + + static var primaryTimeline: String { + localized("primary.timeline") + } + + static var primaryLoading: String { + localized("primary.loading") + } + + static var primaryEmptyDescription: String { + localized("primary.empty.description") + } + + static var primaryCaptionHomeBase: String { + localized("primary.caption.homeBase") + } + + static var primaryCaptionSecondHome: String { + localized("primary.caption.secondHome") + } + + static func primaryEmptyTitle(year: Int) -> String { + String( + localized: "primary.empty.title", + defaultValue: "No travels logged for \(yearText(year))", + bundle: .module, + ) + } + + static func primaryHeaderTitle(year: Int) -> String { + String( + localized: "primary.header.title", + defaultValue: "Where have you been in \(yearText(year))?", + bundle: .module, + ) + } + + static func primaryHeaderSubtitle(count: Int) -> String { + String( + localized: "primary.header.subtitle", + defaultValue: "\(count) days on the map so far", + bundle: .module, + ) + } + + // MARK: Elsewhere + + static var secondaryTitle: String { + localized("secondary.title") + } + + static var secondaryLoading: String { + localized("secondary.loading") + } + + static var secondaryEmptyTitle: String { + localized("secondary.empty.title") + } + + static var secondaryEmptyDescription: String { + localized("secondary.empty.description") + } + + static var secondaryCaptionPassingThrough: String { + localized("secondary.caption.passingThrough") + } + + static func secondaryHeader(year: Int) -> String { + String( + localized: "secondary.header", + defaultValue: "Everywhere else you turned up in \(yearText(year)).", + bundle: .module, + ) + } + + // MARK: Settings + + static var settingsTitle: String { + localized("settings.title") + } + + static var settingsPermissionAlertTitle: String { + localized("settings.permissionAlert.title") + } + + static var settingsPermissionAlertMessage: String { + localized("settings.permissionAlert.message") + } + + static var settingsPermissionAlertOpenSettings: String { + localized("settings.permissionAlert.openSettings") + } + + static var settingsPermissionAlertNotNow: String { + localized("settings.permissionAlert.notNow") + } + + static var settingsLocationHeader: String { + localized("settings.location.header") + } + + static var settingsLocationToggle: String { + localized("settings.location.toggle") + } + + static var settingsLocationGrant: String { + localized("settings.location.grant") + } + + static var settingsLocationFooter: String { + localized("settings.location.footer") + } + + static var settingsManualHeader: String { + localized("settings.manual.header") + } + + static var settingsManualLink: String { + localized("settings.manual.link") + } + + static var settingsManualFooter: String { + localized("settings.manual.footer") + } + + static var settingsDataHeader: String { + localized("settings.data.header") + } + + static var settingsDataCancel: String { + localized("settings.data.cancel") + } + + static func settingsDataErase(year: Int) -> String { + String( + localized: "settings.data.erase", + defaultValue: "Erase \(yearText(year)) data", + bundle: .module, + ) + } + + static func settingsDataConfirmMessage(year: Int) -> String { + String( + localized: "settings.data.confirmMessage", + defaultValue: "This removes every sample, manual day, and piece of evidence in \(yearText(year)). It can't be undone.", + bundle: .module, + ) + } + + static func settingsDataFooter(year: Int) -> String { + String( + localized: "settings.data.footer", + defaultValue: "Acts on the year selected on the Primary tab (\(yearText(year))).", + bundle: .module, + ) + } + + // MARK: Manual entry + + static var manualEntryPickerLabel: String { + localized("manual.entry.pickerLabel") + } + + static var manualModeSingleDay: String { + localized("manual.mode.singleDay") + } + + static var manualModeRange: String { + localized("manual.mode.range") + } + + static var manualDay: String { + localized("manual.day") + } + + static var manualFrom: String { + localized("manual.from") + } + + static var manualThrough: String { + localized("manual.through") + } + + static var manualSingleDayFooter: String { + localized("manual.singleDay.footer") + } + + static var manualRegionsHeader: String { + localized("manual.regions.header") + } + + static var manualRegionsFooter: String { + localized("manual.regions.footer") + } + + static var manualTitle: String { + localized("manual.title") + } + + static var manualSave: String { + localized("manual.save") + } + + static func manualRangeFooter(count: Int) -> String { + String( + localized: "manual.range.footer", + defaultValue: "Backfilling \(count) days.", + bundle: .module, + ) + } + + // MARK: Timeline + + static var timelineDone: String { + localized("timeline.done") + } + + static var timelineEmptyTitle: String { + localized("timeline.empty.title") + } + + static var timelineEmptyDescription: String { + localized("timeline.empty.description") + } + + static func timelineTitle(year: Int) -> String { + String( + localized: "timeline.title", + defaultValue: "Timeline Β· \(yearText(year))", + bundle: .module, + ) + } + + static func timelineRowAccessibility(region: String, range: String, days: Int) -> String { + String( + localized: "timeline.row.accessibility", + defaultValue: "\(region), \(range), \(dayCount(days))", + bundle: .module, + ) + } + + // MARK: Helpers + + private static func localized(_ key: String.LocalizationValue) -> String { + String(localized: key, bundle: .module) + } + + /// Year without a grouping separator ("2026", not "2,026"). + private static func yearText(_ year: Int) -> String { + year.formatted(.number.grouping(.never)) + } +} diff --git a/Where/WhereUI/Sources/Shared/YearSelector.swift b/Where/WhereUI/Sources/Shared/YearSelector.swift index 38c702e..071fea2 100644 --- a/Where/WhereUI/Sources/Shared/YearSelector.swift +++ b/Where/WhereUI/Sources/Shared/YearSelector.swift @@ -18,16 +18,21 @@ struct YearSelector: View { Task { await model.select(year: year) } } label: { if year == model.selectedYear { - Label { Text(verbatim: "\(year)") } icon: { Image(systemName: "checkmark") } + Label { Text(yearText(year)) } icon: { Image(systemName: "checkmark") } } else { - Text(verbatim: "\(year)") + Text(yearText(year)) } } } } label: { - Label { Text(verbatim: "\(model.selectedYear)") } icon: { Image(systemName: "calendar") + Label { Text(yearText(model.selectedYear)) } icon: { Image(systemName: "calendar") } } .accessibilityIdentifier("where_year_selector") } + + /// Year without a grouping separator ("2026", not "2,026"). + private func yearText(_ year: Int) -> String { + year.formatted(.number.grouping(.never)) + } } diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift new file mode 100644 index 0000000..d4956c2 --- /dev/null +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import WhereUI + +/// Verifies the WhereUI string catalog is actually wired up (lookups resolve to +/// English values, not raw keys), that plural variations are honored, and that +/// years are formatted without a grouping separator. +struct StringsTests { + @Test func simpleKeysResolveToCatalogValues() { + #expect(Strings.primaryTitle == "Where") + #expect(Strings.tabElsewhere == "Elsewhere") + #expect(Strings.loadErrorTitle == "Couldn't load your year") + } + + @Test func dayCountUsesPluralVariations() { + #expect(Strings.dayCount(1) == "1 day") + #expect(Strings.dayCount(5) == "5 days") + } + + @Test func dayUnitUsesPluralVariations() { + #expect(Strings.dayUnit(1) == "day") + #expect(Strings.dayUnit(2) == "days") + } + + @Test func yearsAreFormattedWithoutGroupingSeparator() { + #expect(Strings.timelineTitle(year: 2026) == "Timeline Β· 2026") + #expect(Strings.settingsDataErase(year: 2026) == "Erase 2026 data") + } + + @Test func interpolatedStringsSubstituteArguments() { + #expect(Strings.primaryEmptyTitle(year: 2024) == "No travels logged for 2024") + #expect(Strings.primaryHeaderSubtitle(count: 1) == "1 day on the map so far") + #expect(Strings.primaryHeaderSubtitle(count: 12) == "12 days on the map so far") + } +} From bb36ee352fc773d5a428dfc99bb6bca0a2a9fb57 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 13:27:44 -0700 Subject: [PATCH 17/22] Expose location authorization status from WhereCore Adds a Sendable LocationAuthorizationStatus and surfaces it through LocationSource (currentAuthorization() + an authorizationUpdates stream) and WhereController, so the UI can reflect real authorization and react to Settings-app changes. CoreLocationSource now requests When-In-Use first then nudges the (iOS-deferred) Always upgrade without blocking, and broadcasts every authorization change. ScriptedLocationSource gains a configurable status + emitAuthorization for tests. Co-authored-by: Cursor --- .../Sources/Location/CoreLocationSource.swift | 87 +++++++++++++------ .../LocationAuthorizationStatus.swift | 31 +++++++ .../Sources/Location/LocationSource.swift | 57 ++++++++++-- Where/WhereCore/Sources/WhereController.swift | 17 ++++ .../Tests/LocationAuthorizationTests.swift | 45 ++++++++++ 5 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 Where/WhereCore/Sources/Location/LocationAuthorizationStatus.swift create mode 100644 Where/WhereCore/Tests/LocationAuthorizationTests.swift diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index 862f07f..ed7bbd4 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -29,9 +29,12 @@ import Foundation @MainActor public final class CoreLocationSource: NSObject, LocationSource { public nonisolated let sampleStream: AsyncStream + public nonisolated let authorizationUpdates: AsyncStream private let manager: CLLocationManager private nonisolated let sampleContinuation: AsyncStream.Continuation + private nonisolated let authorizationContinuation: AsyncStream + .Continuation /// Continuation for the in-flight `requestPermission()` call. Set /// when the call begins and consumed on the next @@ -49,6 +52,12 @@ public final class CoreLocationSource: NSObject, LocationSource { sampleStream = AsyncStream { sampleCont = $0 } sampleContinuation = sampleCont + var authCont: AsyncStream.Continuation! + // Keep only the latest status so a subscriber that attaches slightly + // after launch still sees the current value. + authorizationUpdates = AsyncStream(bufferingPolicy: .bufferingNewest(1)) { authCont = $0 } + authorizationContinuation = authCont + manager = CLLocationManager() super.init() manager.delegate = self @@ -64,9 +73,11 @@ public final class CoreLocationSource: NSObject, LocationSource { manager.stopMonitoringVisits() } + public func currentAuthorization() async -> LocationAuthorizationStatus { + Self.map(manager.authorizationStatus) + } + public func requestPermission() async throws { - // If the user has already answered, resolve synchronously - // without re-prompting. switch manager.authorizationStatus { case .authorizedAlways: return @@ -74,16 +85,27 @@ public final class CoreLocationSource: NSObject, LocationSource { throw LocationPermissionDeniedError(reason: .denied) case .restricted: throw LocationPermissionDeniedError(reason: .restricted) - case .authorizedWhenInUse, .notDetermined: + case .authorizedWhenInUse: + // Already have foreground access. Nudge the Always upgrade; + // iOS defers this prompt to the next background transition, so + // don't block β€” the authorization stream reports the result. + manager.requestAlwaysAuthorization() + return + case .notDetermined: break @unknown default: - break + return } - // Otherwise drive the prompt and wait for the next - // `locationManagerDidChangeAuthorization` callback. + // Drive the initial prompt and wait for the user's first decision. try await withCheckedThrowingContinuation { continuation in pendingPermissionContinuation = continuation + manager.requestWhenInUseAuthorization() + } + + // If we only got When-In-Use, kick off the (deferred) Always upgrade + // without blocking; observers learn the outcome via the stream. + if manager.authorizationStatus == .authorizedWhenInUse { manager.requestAlwaysAuthorization() } } @@ -91,36 +113,42 @@ public final class CoreLocationSource: NSObject, LocationSource { /// Resume the in-flight `requestPermission()` continuation, if any, /// based on the manager's new authorization status. Called from the /// `nonisolated` delegate method after it hops back to `@MainActor`. + /// + /// Any granted status (When-In-Use or Always) resolves successfully β€” the + /// caller inspects `currentAuthorization()` to decide whether Always was + /// obtained. This avoids hanging on the deferred Always prompt, which may + /// never deliver a follow-up callback if the user leaves it at When-In-Use. fileprivate func resolvePendingPermission(for status: CLAuthorizationStatus) { guard let continuation = pendingPermissionContinuation else { return } - pendingPermissionContinuation = nil switch status { - case .authorizedAlways: + case .authorizedAlways, .authorizedWhenInUse: + pendingPermissionContinuation = nil continuation.resume() - case .authorizedWhenInUse: - // The user picked While-Using; the app needs Always to - // do its job. Treat as denied so the UI shows the - // upgrade-to-Always Settings link rather than silently - // hanging. - continuation.resume( - throwing: LocationPermissionDeniedError(reason: .denied), - ) case .denied: - continuation.resume( - throwing: LocationPermissionDeniedError(reason: .denied), - ) + pendingPermissionContinuation = nil + continuation.resume(throwing: LocationPermissionDeniedError(reason: .denied)) case .restricted: - continuation.resume( - throwing: LocationPermissionDeniedError(reason: .restricted), - ) + pendingPermissionContinuation = nil + continuation.resume(throwing: LocationPermissionDeniedError(reason: .restricted)) case .notDetermined: - // Still waiting on the user; do not resume. The next - // authorization change will land us here again. - pendingPermissionContinuation = continuation + // Still waiting on the user; keep the continuation pending. + break @unknown default: - continuation.resume( - throwing: LocationPermissionDeniedError(reason: .denied), - ) + pendingPermissionContinuation = nil + continuation.resume(throwing: LocationPermissionDeniedError(reason: .denied)) + } + } + + fileprivate nonisolated static func map(_ status: CLAuthorizationStatus) + -> LocationAuthorizationStatus + { + switch status { + case .authorizedAlways: .always + case .authorizedWhenInUse: .whenInUse + case .denied: .denied + case .restricted: .restricted + case .notDetermined: .notDetermined + @unknown default: .notDetermined } } } @@ -173,6 +201,9 @@ extension CoreLocationSource: CLLocationManagerDelegate { public nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus + // Broadcast every change so observers (the UI) stay in sync, including + // changes made in the Settings app while we were backgrounded. + authorizationContinuation.yield(Self.map(status)) Task { @MainActor in self.resolvePendingPermission(for: status) } diff --git a/Where/WhereCore/Sources/Location/LocationAuthorizationStatus.swift b/Where/WhereCore/Sources/Location/LocationAuthorizationStatus.swift new file mode 100644 index 0000000..e3c3d73 --- /dev/null +++ b/Where/WhereCore/Sources/Location/LocationAuthorizationStatus.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Domain-level mirror of `CLAuthorizationStatus`, kept free of CoreLocation so +/// it can cross actor boundaries (`Sendable`) and be rendered by the UI layer +/// without importing CoreLocation. +public enum LocationAuthorizationStatus: Sendable, Hashable { + /// The user hasn't been asked yet. + case notDetermined + /// Denied by a device-level restriction (e.g. parental controls). + case restricted + /// The user explicitly declined. + case denied + /// Granted only while the app is in use β€” not enough for the background, + /// cross-launch tracking this app relies on. + case whenInUse + /// Granted always, including background β€” the only status that supports + /// passive background tracking. + case always + + /// Whether this status permits the background significant-change / visit + /// monitoring the app uses to log presence across launches. + public var allowsBackgroundTracking: Bool { + self == .always + } + + /// Whether the user has actively refused (vs. simply not been asked), so + /// the UI can route to the Settings app instead of re-prompting. + public var isDenied: Bool { + self == .denied || self == .restricted + } +} diff --git a/Where/WhereCore/Sources/Location/LocationSource.swift b/Where/WhereCore/Sources/Location/LocationSource.swift index 7eedec7..34f6517 100644 --- a/Where/WhereCore/Sources/Location/LocationSource.swift +++ b/Where/WhereCore/Sources/Location/LocationSource.swift @@ -30,41 +30,72 @@ public struct LocationPermissionDeniedError: Error, Sendable, Hashable { public protocol LocationSource: AnyObject, Sendable { var sampleStream: AsyncStream { get } + /// A stream of authorization-status changes. Yields whenever the system + /// reports a new status (including the deferred "upgrade to Always" + /// decision), so the UI can stay in sync with Settings changes made + /// outside the app. Read `currentAuthorization()` for the value at a + /// point in time. + var authorizationUpdates: AsyncStream { get } + func start() async func stop() async - /// Drive the permission flow to completion. Resolves successfully - /// when the user has granted Always authorization. Throws - /// `LocationPermissionDeniedError` on `.denied` / `.restricted`. - /// Call once at app launch (or when re-entering a permission-gated - /// screen) and `do/catch` the result β€” there is no separate stream - /// to subscribe to. + /// The current authorization status, read on demand. + func currentAuthorization() async -> LocationAuthorizationStatus + + /// Drive the permission flow. From `.notDetermined` this presents the + /// system prompt and resolves once the user decides; it then nudges the + /// (iOS-deferred) Always upgrade without blocking on it. Resolves + /// successfully for any granted status and throws + /// `LocationPermissionDeniedError` on `.denied` / `.restricted`. Callers + /// should read `currentAuthorization()` afterwards (or observe + /// `authorizationUpdates`) since "granted" may mean only When-In-Use. func requestPermission() async throws } /// Hand-driven `LocationSource` for tests and SwiftUI previews. Yield samples /// with `emit(_:)`; close the stream with `finish()` when the test is done. +/// Drive authorization with `emitAuthorization(_:)`. public final class ScriptedLocationSource: LocationSource, @unchecked Sendable { public let sampleStream: AsyncStream + public let authorizationUpdates: AsyncStream private let sampleContinuation: AsyncStream.Continuation + private let authorizationContinuation: AsyncStream.Continuation private let permissionResult: Result - /// - Parameter permissionResult: what the next call to - /// `requestPermission()` returns. Defaults to success so existing - /// tests don't need to opt in. + private let lock = NSLock() + private var _status: LocationAuthorizationStatus + + /// - Parameters: + /// - permissionResult: what the next call to `requestPermission()` + /// returns. Defaults to success so existing tests don't need to opt in. + /// - authorizationStatus: the initial status reported by + /// `currentAuthorization()`. Defaults to `.always` so tracking-oriented + /// tests see a granted source. public init( permissionResult: Result = .success(()), + authorizationStatus: LocationAuthorizationStatus = .always, ) { var sampleCont: AsyncStream.Continuation! sampleStream = AsyncStream { sampleCont = $0 } sampleContinuation = sampleCont + + var authCont: AsyncStream.Continuation! + authorizationUpdates = AsyncStream { authCont = $0 } + authorizationContinuation = authCont + self.permissionResult = permissionResult + _status = authorizationStatus } public func start() async {} public func stop() async {} + public func currentAuthorization() async -> LocationAuthorizationStatus { + lock.withLock { _status } + } + public func requestPermission() async throws { try permissionResult.get() } @@ -73,7 +104,15 @@ public final class ScriptedLocationSource: LocationSource, @unchecked Sendable { sampleContinuation.yield(sample) } + /// Update the reported authorization status and notify observers, the way + /// CoreLocation would after a prompt or a Settings change. + public func emitAuthorization(_ status: LocationAuthorizationStatus) { + lock.withLock { _status = status } + authorizationContinuation.yield(status) + } + public func finish() { sampleContinuation.finish() + authorizationContinuation.finish() } } diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index e43e374..f3eb41f 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -211,4 +211,21 @@ public actor WhereController { public func requestLocationPermission() async throws { try await locationSource.requestPermission() } + + /// The current location authorization status. + public func authorizationStatus() async -> LocationAuthorizationStatus { + await locationSource.currentAuthorization() + } + + /// Live stream of authorization-status changes (system prompt results and + /// Settings-app changes). Subscribe once and iterate. + public func authorizationUpdates() -> AsyncStream { + locationSource.authorizationUpdates + } + + /// Whether the GPS ingestion stream is currently attached. Exposed so the + /// view-model can reconcile its tracking flag with reality after launch. + public var isTrackingActive: Bool { + ingestTask != nil + } } diff --git a/Where/WhereCore/Tests/LocationAuthorizationTests.swift b/Where/WhereCore/Tests/LocationAuthorizationTests.swift new file mode 100644 index 0000000..537d663 --- /dev/null +++ b/Where/WhereCore/Tests/LocationAuthorizationTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +import WhereCore + +struct LocationAuthorizationTests { + private func makeController( + status: LocationAuthorizationStatus, + ) throws -> (WhereController, ScriptedLocationSource) { + let store = try SwiftDataStore.inMemory() + let source = ScriptedLocationSource(authorizationStatus: status) + return (WhereController(store: store, locationSource: source), source) + } + + @Test func authorizationStatusReflectsSource() async throws { + let (controller, _) = try makeController(status: .whenInUse) + #expect(await controller.authorizationStatus() == .whenInUse) + } + + @Test func authorizationUpdatesYieldChanges() async throws { + let (controller, source) = try makeController(status: .notDetermined) + let updates = await controller.authorizationUpdates() + source.emitAuthorization(.always) + + var received: LocationAuthorizationStatus? + for await status in updates { + received = status + break + } + #expect(received == .always) + } + + @Test func allowsBackgroundTrackingOnlyForAlways() { + #expect(LocationAuthorizationStatus.always.allowsBackgroundTracking) + #expect(!LocationAuthorizationStatus.whenInUse.allowsBackgroundTracking) + #expect(!LocationAuthorizationStatus.notDetermined.allowsBackgroundTracking) + #expect(!LocationAuthorizationStatus.denied.allowsBackgroundTracking) + } + + @Test func isDeniedCoversDeniedAndRestricted() { + #expect(LocationAuthorizationStatus.denied.isDenied) + #expect(LocationAuthorizationStatus.restricted.isDenied) + #expect(!LocationAuthorizationStatus.whenInUse.isDenied) + #expect(!LocationAuthorizationStatus.notDetermined.isDenied) + } +} From 9df9b1cc357cf2f19612152c539338ec16659631 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 13:27:53 -0700 Subject: [PATCH 18/22] Reconcile background tracking and show location status in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two bugs and adds the requested indicator: - "Track in the background" no longer reads off on every launch β€” the model now syncs authorization, persists the user's tracking intent, and resumes GPS when Always is granted (start() previously never started GPS at all). - "Grant location access" now produces visible feedback: the model reflects the authorization result and the button is context-aware (Grant only when it can help; Open Settings when denied/limited). - Adds a LocationStatusRow to the Settings location section summarizing authorization + whether tracking is actually running, kept live via WhereController.authorizationUpdates(). New strings go through the catalog; WhereModelTrackingTests covers the launch reconciliation and live-update paths. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/WhereModel.swift | 97 ++++++++++++++++--- .../Sources/Resources/Localizable.xcstrings | 66 +++++++++++++ .../Sources/Settings/LocationStatusRow.swift | 91 +++++++++++++++++ .../Sources/Settings/SettingsView.swift | 38 +++++++- Where/WhereUI/Sources/Shared/Strings.swift | 24 +++++ .../WhereUI/Sources/Shared/UIConstants.swift | 1 + .../Tests/WhereModelTrackingTests.swift | 95 ++++++++++++++++++ 7 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/LocationStatusRow.swift create mode 100644 Where/WhereUI/Tests/WhereModelTrackingTests.swift diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 7b789fd..fec8d86 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -21,13 +21,32 @@ public final class WhereModel { public private(set) var selectedYear: Int public private(set) var report: YearReport? public private(set) var loadState: LoadState = .idle + + /// Whether background GPS ingestion is currently attached. Reflects reality + /// (authorization + the user's intent), not just the last button tap. public private(set) var isTracking = false + /// The latest known location authorization status, kept live via + /// `WhereController.authorizationUpdates()`. + public private(set) var authorizationStatus: LocationAuthorizationStatus = .notDetermined + /// Set when a location-permission request comes back denied/restricted, /// so the UI can offer to open Settings. public var permissionDenied = false private var controller: WhereController? + private var authorizationTask: Task? + private let defaults: UserDefaults + + /// Persisted user intent to track in the background. Effective tracking is + /// this AND `.always` authorization; we default to `true` so that, once the + /// user grants Always, tracking resumes automatically on every launch. + private var wantsTracking: Bool { + get { defaults.object(forKey: Self.wantsTrackingKey) as? Bool ?? true } + set { defaults.set(newValue, forKey: Self.wantsTrackingKey) } + } + + private static let wantsTrackingKey = "where.wantsBackgroundTracking" /// Primary/secondary split of the current report, or an empty ranking /// while nothing is loaded. @@ -61,8 +80,12 @@ public final class WhereModel { Calendar.current.component(.year, from: Date()) } - public init(selectedYear: Int = WhereModel.currentYear) { + public init( + selectedYear: Int = WhereModel.currentYear, + defaults: UserDefaults = .standard, + ) { self.selectedYear = selectedYear + self.defaults = defaults } /// Preview/test seam: inject an already-built controller (and optionally a @@ -72,16 +95,19 @@ public final class WhereModel { controller: WhereController, report: YearReport? = nil, selectedYear: Int = WhereModel.currentYear, + defaults: UserDefaults = .standard, ) { self.controller = controller self.report = report self.selectedYear = selectedYear + self.defaults = defaults loadState = report == nil ? .idle : .loaded } /// Build the production controller (SwiftData + CoreLocation) on first - /// appearance, then load the selected year. Safe to call repeatedly; the - /// controller is only built once. + /// appearance, sync authorization, resume tracking if appropriate, then + /// load the selected year. Safe to call repeatedly; the controller and the + /// authorization observer are only set up once. public func start() async { if controller == nil { do { @@ -95,9 +121,47 @@ public final class WhereModel { return } } + await syncAuthorization() + observeAuthorizationChanges() + await reconcileTracking() await refresh() } + /// Read the current authorization status from the controller into our + /// observable state. Does not surface the permission alert β€” that's + /// reserved for explicit user actions. + private func syncAuthorization() async { + guard let controller else { return } + authorizationStatus = await controller.authorizationStatus() + } + + /// Subscribe to live authorization changes (prompt results, Settings-app + /// edits) so the indicator and tracking state stay in sync. Idempotent. + private func observeAuthorizationChanges() { + guard authorizationTask == nil, let controller else { return } + authorizationTask = Task { @MainActor [weak self] in + let updates = await controller.authorizationUpdates() + for await status in updates { + guard let self else { break } + authorizationStatus = status + await reconcileTracking() + } + } + } + + /// Start or stop GPS ingestion so it matches the user's intent and the + /// current authorization. Tracking only runs with Always authorization. + private func reconcileTracking() async { + guard let controller else { return } + if wantsTracking, authorizationStatus.allowsBackgroundTracking { + await controller.startGPS() + isTracking = true + } else { + await controller.stopGPS() + isTracking = false + } + } + public func select(year: Int) async { guard year != selectedYear else { return } selectedYear = year @@ -138,39 +202,44 @@ public final class WhereModel { } } + /// Explicitly (re)request location access, e.g. from the "Grant location + /// access" button. Drives the system prompt when possible, then syncs the + /// status and reconciles tracking so the UI reflects the outcome. public func requestPermission() async { guard let controller else { return } do { try await controller.requestLocationPermission() permissionDenied = false } catch { - // Both `LocationPermissionDeniedError` and any unexpected failure - // mean we don't have Always access, so route the user to Settings. + // `.denied` / `.restricted` mean re-prompting won't help, so the UI + // routes the user to the Settings app. permissionDenied = true } + await syncAuthorization() + await reconcileTracking() } - /// Turn on background tracking. Confirms (and, if needed, requests) - /// location permission first, and only reports `isTracking` once GPS is - /// actually running β€” otherwise the toggle would read "on" while - /// CoreLocation silently produces nothing. On denial the Settings alert is - /// surfaced and tracking stays off. + /// Turn on background tracking. Records the intent, requests permission if + /// needed, then reconciles β€” `isTracking` only flips on once Always + /// authorization is in hand and GPS is actually running. When only + /// When-In-Use is granted the indicator guides the user to Settings; on a + /// hard denial the Settings alert is surfaced. public func startTracking() async { guard let controller else { return } + wantsTracking = true do { try await controller.requestLocationPermission() permissionDenied = false } catch { permissionDenied = true - isTracking = false - return } - await controller.startGPS() - isTracking = true + await syncAuthorization() + await reconcileTracking() } public func stopTracking() async { guard let controller else { return } + wantsTracking = false await controller.stopGPS() isTracking = false } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 7d5963b..7a3fb31 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -565,6 +565,72 @@ } } }, + "settings.status.alwaysPaused" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always allowed (paused)" + } + } + } + }, + "settings.status.denied" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location access denied" + } + } + } + }, + "settings.status.notDetermined" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location access not set up" + } + } + } + }, + "settings.status.restricted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location access restricted" + } + } + } + }, + "settings.status.tracking" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracking in the background" + } + } + } + }, + "settings.status.whenInUse" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "While Using only β€” needs Always" + } + } + } + }, "settings.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift new file mode 100644 index 0000000..56eea29 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift @@ -0,0 +1,91 @@ +import SwiftUI +import WhereCore + +/// A compact status line for the Settings location section: an icon + label +/// summarizing the current authorization and whether background tracking is +/// actually running. +struct LocationStatusRow: View { + let status: LocationAuthorizationStatus + let isTracking: Bool + + var body: some View { + HStack(spacing: UIConstants.Spacings.large) { + Image(systemName: presentation.symbol) + .font(.title3) + .foregroundStyle(presentation.tint) + .frame(width: UIConstants.Size.statusIconWidth) + .accessibilityHidden(true) + + Text(presentation.title) + .font(.subheadline) + + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(presentation.title) + } + + private struct Presentation { + let symbol: String + let tint: Color + let title: String + } + + private var presentation: Presentation { + // Active tracking trumps the raw status: it's the happy path. + if isTracking { + return Presentation( + symbol: "location.fill", + tint: .green, + title: Strings.settingsStatusTracking, + ) + } + switch status { + case .always: + return Presentation( + symbol: "location.fill", + tint: .green, + title: Strings.settingsStatusAlwaysPaused, + ) + case .whenInUse: + return Presentation( + symbol: "location", + tint: .orange, + title: Strings.settingsStatusWhenInUse, + ) + case .notDetermined: + return Presentation( + symbol: "location.slash", + tint: .secondary, + title: Strings.settingsStatusNotDetermined, + ) + case .denied: + return Presentation( + symbol: "location.slash.fill", + tint: .red, + title: Strings.settingsStatusDenied, + ) + case .restricted: + return Presentation( + symbol: "lock.fill", + tint: .red, + title: Strings.settingsStatusRestricted, + ) + } + } +} + +#if DEBUG + #Preview { + Form { + Section { + LocationStatusRow(status: .always, isTracking: true) + LocationStatusRow(status: .always, isTracking: false) + LocationStatusRow(status: .whenInUse, isTracking: false) + LocationStatusRow(status: .notDetermined, isTracking: false) + LocationStatusRow(status: .denied, isTracking: false) + LocationStatusRow(status: .restricted, isTracking: false) + } + } + } +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 0a4ad80..e6b382b 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -31,13 +31,26 @@ struct SettingsView: View { private var trackingSection: some View { Section { + LocationStatusRow(status: model.authorizationStatus, isTracking: model.isTracking) + Toggle(isOn: trackingBinding) { Label(Strings.settingsLocationToggle, systemImage: "location.fill") } - Button { - Task { await model.requestPermission() } - } label: { - Label(Strings.settingsLocationGrant, systemImage: "location.magnifyingglass") + + if showGrantButton { + Button { + Task { await model.requestPermission() } + } label: { + Label(Strings.settingsLocationGrant, systemImage: "location.magnifyingglass") + } + } + + if showOpenSettingsButton { + Button { + openSystemSettings() + } label: { + Label(Strings.settingsPermissionAlertOpenSettings, systemImage: "gear") + } } } header: { Text(Strings.settingsLocationHeader) @@ -46,6 +59,23 @@ struct SettingsView: View { } } + /// Re-requesting only helps before the user has made a final decision. + private var showGrantButton: Bool { + switch model.authorizationStatus { + case .notDetermined, .whenInUse: true + default: false + } + } + + /// Once access is denied/restricted (or stuck at When-In-Use), the only way + /// forward is the Settings app. + private var showOpenSettingsButton: Bool { + switch model.authorizationStatus { + case .denied, .restricted, .whenInUse: true + default: false + } + } + private var manualEntrySection: some View { Section { NavigationLink { diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 37e69d7..0b28376 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -164,6 +164,30 @@ enum Strings { localized("settings.location.footer") } + static var settingsStatusTracking: String { + localized("settings.status.tracking") + } + + static var settingsStatusAlwaysPaused: String { + localized("settings.status.alwaysPaused") + } + + static var settingsStatusWhenInUse: String { + localized("settings.status.whenInUse") + } + + static var settingsStatusNotDetermined: String { + localized("settings.status.notDetermined") + } + + static var settingsStatusDenied: String { + localized("settings.status.denied") + } + + static var settingsStatusRestricted: String { + localized("settings.status.restricted") + } + static var settingsManualHeader: String { localized("settings.manual.header") } diff --git a/Where/WhereUI/Sources/Shared/UIConstants.swift b/Where/WhereUI/Sources/Shared/UIConstants.swift index 457d841..0c7b538 100644 --- a/Where/WhereUI/Sources/Shared/UIConstants.swift +++ b/Where/WhereUI/Sources/Shared/UIConstants.swift @@ -34,5 +34,6 @@ enum UIConstants { static let timelineAccentWidth: CGFloat = 4 static let timelineAccentHeight: CGFloat = 34 static let heroNumberFontSize: CGFloat = 46 + static let statusIconWidth: CGFloat = 28 } } diff --git a/Where/WhereUI/Tests/WhereModelTrackingTests.swift b/Where/WhereUI/Tests/WhereModelTrackingTests.swift new file mode 100644 index 0000000..1c84691 --- /dev/null +++ b/Where/WhereUI/Tests/WhereModelTrackingTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +import WhereCore +import WhereUI + +/// Covers the launch-time reconciliation that fixes the "toggle is always off" +/// and "Grant does nothing" bugs: tracking and the authorization indicator must +/// reflect real authorization + persisted intent, not just the last tap. +@MainActor +struct WhereModelTrackingTests { + private func ephemeralDefaults() -> UserDefaults { + let suite = "test.WhereModelTracking.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + private func makeModel( + status: LocationAuthorizationStatus, + defaults: UserDefaults, + ) throws -> (WhereModel, ScriptedLocationSource) { + let source = ScriptedLocationSource(authorizationStatus: status) + let controller = try WhereController( + store: SwiftDataStore.inMemory(), + locationSource: source, + ) + let model = WhereModel(controller: controller, defaults: defaults) + return (model, source) + } + + @Test func launchWithAlwaysResumesTracking() async throws { + let (model, _) = try makeModel(status: .always, defaults: ephemeralDefaults()) + await model.start() + #expect(model.authorizationStatus == .always) + #expect(model.isTracking) + #expect(!model.permissionDenied) + } + + @Test func launchWithWhenInUseDoesNotTrack() async throws { + let (model, _) = try makeModel(status: .whenInUse, defaults: ephemeralDefaults()) + await model.start() + #expect(model.authorizationStatus == .whenInUse) + #expect(!model.isTracking) + } + + @Test func launchWithDeniedDoesNotTrackOrAlert() async throws { + let (model, _) = try makeModel(status: .denied, defaults: ephemeralDefaults()) + await model.start() + #expect(model.authorizationStatus == .denied) + #expect(!model.isTracking) + // Launch must not pop the Settings alert; that's reserved for taps. + #expect(!model.permissionDenied) + } + + @Test func stoppingTrackingPersistsAcrossLaunches() async throws { + let defaults = ephemeralDefaults() + let (model, _) = try makeModel(status: .always, defaults: defaults) + await model.start() + #expect(model.isTracking) + + await model.stopTracking() + #expect(!model.isTracking) + + // A fresh model sharing the same defaults should stay paused even + // though authorization is still Always. + let (relaunched, _) = try makeModel(status: .always, defaults: defaults) + await relaunched.start() + #expect(!relaunched.isTracking) + } + + @Test func grantingLaterStartsTrackingViaLiveUpdates() async throws { + let (model, source) = try makeModel(status: .notDetermined, defaults: ephemeralDefaults()) + await model.start() + #expect(!model.isTracking) + + // Simulate the user granting Always in the system prompt / Settings. + source.emitAuthorization(.always) + + await waitUntil { model.isTracking } + #expect(model.authorizationStatus == .always) + #expect(model.isTracking) + } + + private func waitUntil( + timeout: Duration = .seconds(2), + _ predicate: () -> Bool, + ) async { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if predicate() { return } + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(predicate(), "condition was not met before timeout") + } +} From f1c0871a53a77b2f92c31ba7bd651b2483e76f9c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 13:37:23 -0700 Subject: [PATCH 19/22] Wire location startup to app launch for background relaunch Move controller/CLLocationManager creation off a SwiftUI .task and onto an app-launch hook so CoreLocation can deliver significant-change / visit events when the app is relaunched into the background after termination (a view's .task isn't a reliable hook with no UI). - WhereModel.bootstrap(): synchronous controller (and CLLocationManager) creation; start() now calls it. - AppDelegate (UIApplicationDelegateAdaptor) owns the single WhereModel, bootstraps synchronously in didFinishLaunching, then re-registers monitoring / reconciles tracking; RootView takes the injected model. Co-authored-by: Cursor --- Where/Where/Sources/AppDelegate.swift | 35 ++++++++++++++++ Where/Where/Sources/WhereApp.swift | 4 +- Where/WhereUI/Sources/Model/WhereModel.swift | 43 ++++++++++++-------- Where/WhereUI/Sources/RootView.swift | 14 ++++++- 4 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 Where/Where/Sources/AppDelegate.swift diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift new file mode 100644 index 0000000..6b02ebd --- /dev/null +++ b/Where/Where/Sources/AppDelegate.swift @@ -0,0 +1,35 @@ +import os +import UIKit +import WhereUI + +/// Owns the app's single `WhereModel` and wires location up at process launch +/// rather than from a SwiftUI view's `.task`. +/// +/// This matters for background relaunch: when CoreLocation relaunches the app +/// after termination (a significant location change or visit), there's no UI, +/// so a view's `.task` is not a reliable hook. `didFinishLaunching` always +/// runs, so building the controller (and `CLLocationManager`) here lets +/// CoreLocation deliver the pending event, and re-starting GPS continues +/// background tracking when the user intends it and Always is granted. +@MainActor +final class AppDelegate: NSObject, UIApplicationDelegate { + let model = WhereModel() + + private let logger = Logger(subsystem: "com.stuff.where", category: "AppDelegate") + + func application( + _: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil, + ) -> Bool { + if launchOptions?[.location] != nil { + logger.info("Relaunched by CoreLocation for a background location event") + } + + // Build the controller + CLLocationManager synchronously so a location + // event delivered during launch isn't lost, then (re)register + // monitoring and reconcile tracking off the main thread. + model.bootstrap() + Task { await model.start() } + return true + } +} diff --git a/Where/Where/Sources/WhereApp.swift b/Where/Where/Sources/WhereApp.swift index 9ccfbc0..28ff7f2 100644 --- a/Where/Where/Sources/WhereApp.swift +++ b/Where/Where/Sources/WhereApp.swift @@ -3,9 +3,11 @@ import WhereUI @main struct WhereApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + var body: some Scene { WindowGroup { - RootView() + RootView(model: appDelegate.model) } } } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index fec8d86..a9b045a 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -104,23 +104,34 @@ public final class WhereModel { loadState = report == nil ? .idle : .loaded } - /// Build the production controller (SwiftData + CoreLocation) on first - /// appearance, sync authorization, resume tracking if appropriate, then - /// load the selected year. Safe to call repeatedly; the controller and the - /// authorization observer are only set up once. - public func start() async { - if controller == nil { - do { - let store = try SwiftDataStore.make() - controller = WhereController( - store: store, - locationSource: CoreLocationSource(), - ) - } catch { - loadState = .failed(error.localizedDescription) - return - } + /// Synchronously build the production controller (SwiftData + + /// CoreLocation) if it doesn't exist yet. Idempotent. + /// + /// Constructing `CoreLocationSource` here creates the `CLLocationManager` + /// and installs its delegate, which CoreLocation requires to happen early + /// in app launch so it can deliver significant-change / visit events when + /// the app is relaunched into the background after termination. The app + /// delegate calls this from `didFinishLaunching`; `start()` also calls it + /// to cover the preview/no-delegate path. + public func bootstrap() { + guard controller == nil else { return } + do { + let store = try SwiftDataStore.make() + controller = WhereController( + store: store, + locationSource: CoreLocationSource(), + ) + } catch { + loadState = .failed(error.localizedDescription) } + } + + /// Ensure the controller exists, sync authorization, resume tracking if + /// appropriate, then load the selected year. Safe to call repeatedly; the + /// controller and the authorization observer are only set up once. + public func start() async { + bootstrap() + guard controller != nil else { return } await syncAuthorization() observeAuthorizationChanges() await reconcileTracking() diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index d674ced..bd266ab 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -5,9 +5,19 @@ import WhereCore /// Owns the single `WhereModel`, builds the live controller on appear, and /// hands the model down through the environment. public struct RootView: View { - @State private var model = WhereModel() + @State private var model: WhereModel - public init() {} + /// Inject the app-owned model that was built at launch (so CoreLocation is + /// already wired up for background relaunch). The app uses this. + public init(model: WhereModel) { + _model = State(initialValue: model) + } + + /// Convenience for previews and the hosted UI test, which don't need the + /// launch-built model. + public init() { + _model = State(initialValue: WhereModel()) + } public var body: some View { TabView { From 6d5bbca1c14b8fe4d37012da0726d46ef90e1dfc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 14:02:08 -0700 Subject: [PATCH 20/22] WhereCore: make GPS restartable and coalesce permission requests Addresses two concurrency findings from PR review bots: - stopGPS() previously cancelled the task iterating the single AsyncStream from LocationSource, which terminates the stream. A later startGPS() then iterated an already-finished stream and dropped every subsequent sample, so toggling tracking off and back on (without relaunching) silently stopped recording. The ingest task is now created once and kept alive for the controller's lifetime; stopGPS() only pauses the underlying monitoring (tracked via a new isMonitoring flag) and the task is torn down on deinit. - A second .notDetermined requestPermission() call overwrote the single pending continuation, permanently stranding the first caller. Pending waiters are now coalesced into a list: only the first drives the system prompt and all resume together on the authorization callback. Adds a regression test proving tracking survives a pause/resume. Co-authored-by: Cursor --- .../Sources/Location/CoreLocationSource.swift | 53 +++++++++++++------ Where/WhereCore/Sources/WhereController.swift | 47 ++++++++++------ .../Tests/WhereControllerTests.swift | 23 ++++++++ 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index ed7bbd4..24c55a6 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -36,10 +36,13 @@ public final class CoreLocationSource: NSObject, LocationSource { private nonisolated let authorizationContinuation: AsyncStream .Continuation - /// Continuation for the in-flight `requestPermission()` call. Set - /// when the call begins and consumed on the next - /// `locationManagerDidChangeAuthorization` callback. - private var pendingPermissionContinuation: CheckedContinuation? + /// Continuations for in-flight `requestPermission()` calls. Overlapping + /// callers (e.g. rapid taps, or the toggle and the "Grant" button racing) + /// are coalesced: only the first call drives the system prompt, and every + /// waiter is resumed together on the next authorization callback. Storing + /// a single continuation here would let a second request overwrite β€” and + /// thus permanently strand β€” the first. + private var pendingPermissionContinuations: [CheckedContinuation] = [] override public init() { // The "create stream, capture its continuation" two-step is @@ -98,9 +101,13 @@ public final class CoreLocationSource: NSObject, LocationSource { } // Drive the initial prompt and wait for the user's first decision. + // Only the first concurrent caller triggers the system prompt; any + // others simply join the waiter list and resume on the same callback. try await withCheckedThrowingContinuation { continuation in - pendingPermissionContinuation = continuation - manager.requestWhenInUseAuthorization() + pendingPermissionContinuations.append(continuation) + if pendingPermissionContinuations.count == 1 { + manager.requestWhenInUseAuthorization() + } } // If we only got When-In-Use, kick off the (deferred) Always upgrade @@ -119,23 +126,37 @@ public final class CoreLocationSource: NSObject, LocationSource { /// obtained. This avoids hanging on the deferred Always prompt, which may /// never deliver a follow-up callback if the user leaves it at When-In-Use. fileprivate func resolvePendingPermission(for status: CLAuthorizationStatus) { - guard let continuation = pendingPermissionContinuation else { return } + guard !pendingPermissionContinuations.isEmpty else { return } switch status { case .authorizedAlways, .authorizedWhenInUse: - pendingPermissionContinuation = nil - continuation.resume() + resumePendingPermission(with: .success(())) case .denied: - pendingPermissionContinuation = nil - continuation.resume(throwing: LocationPermissionDeniedError(reason: .denied)) + resumePendingPermission( + with: .failure(LocationPermissionDeniedError(reason: .denied)), + ) case .restricted: - pendingPermissionContinuation = nil - continuation.resume(throwing: LocationPermissionDeniedError(reason: .restricted)) + resumePendingPermission( + with: .failure(LocationPermissionDeniedError(reason: .restricted)), + ) case .notDetermined: - // Still waiting on the user; keep the continuation pending. + // Still waiting on the user; keep the continuations pending. break @unknown default: - pendingPermissionContinuation = nil - continuation.resume(throwing: LocationPermissionDeniedError(reason: .denied)) + resumePendingPermission( + with: .failure(LocationPermissionDeniedError(reason: .denied)), + ) + } + } + + /// Resume (and clear) every coalesced permission waiter with the same + /// outcome. Cleared before resuming so a re-entrant `requestPermission()` + /// from a resumed continuation starts a fresh request rather than racing + /// the list we're draining. + private func resumePendingPermission(with result: Result) { + let waiters = pendingPermissionContinuations + pendingPermissionContinuations.removeAll() + for waiter in waiters { + waiter.resume(with: result) } } diff --git a/Where/WhereCore/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index f3eb41f..e1034d5 100644 --- a/Where/WhereCore/Sources/WhereController.swift +++ b/Where/WhereCore/Sources/WhereController.swift @@ -7,7 +7,8 @@ import os /// /// - GPS sampling is opt-in: callers invoke `startGPS()` once the user grants /// authorization. Ingestion runs in an unstructured `Task` owned by the -/// actor so it can be cancelled by `stopGPS()` / `deinit`. +/// actor; `stopGPS()` pauses the underlying monitoring while leaving the +/// task alive (so the stream can be resumed), and `deinit` cancels it. /// - Retroactive entry uses `addManualSample(_:)` (a single coordinate) or /// `addManualDay(date:regions:)` (an authoritative day overlay that unions /// with whatever GPS produced for that day). @@ -21,6 +22,11 @@ public actor WhereController { private var ingestTask: Task? + /// Whether the underlying location monitoring is currently active. Tracked + /// separately from `ingestTask` because the ingestion task outlives a + /// `stopGPS()` pause (see `startGPS()` for why). + private var isMonitoring = false + /// Samples whose persist call failed (e.g. transient SwiftData / CloudKit /// error). Drained before each new GPS save and on the next `startGPS()` /// so a brief I/O outage doesn't silently drop measurements. @@ -127,15 +133,24 @@ public actor WhereController { // MARK: - GPS lifecycle + /// Begin (or resume) GPS ingestion. Idempotent: a second call while + /// monitoring is already active is a no-op, so the lifecycle is safe to + /// drive from multiple call sites (e.g. scene activation + a manual + /// toggle). + /// + /// The task that drains `locationSource.sampleStream` is created once and + /// then kept alive for the controller's lifetime; `stopGPS()` only pauses + /// the underlying monitoring. Cancelling that task would terminate the + /// single-consumer `AsyncStream`, so a later `startGPS()` would iterate an + /// already-finished stream and silently drop every subsequent sample. public func startGPS() async { - // Idempotent: a second call while a stream is already attached - // is a no-op so the GPS lifecycle is safe to drive from - // multiple call sites (e.g. scene activation + manual button). - guard ingestTask == nil else { return } + guard !isMonitoring else { return } + isMonitoring = true await locationSource.start() // Flush anything that failed to persist before this session - // started, before we attach the new stream. + // started, before we (re)attach the stream consumer. await drainRetryQueue() + guard ingestTask == nil else { return } let stream = locationSource.sampleStream ingestTask = Task { [weak self] in for await sample in stream { @@ -198,13 +213,15 @@ public actor WhereController { retryQueue.count } + /// Pause GPS ingestion by stopping the underlying location monitoring. + /// Idempotent and safe to call from teardown paths that may run before + /// any `startGPS()` (e.g. scene background, error recovery). The + /// ingestion task is intentionally left running (see `startGPS()`); it + /// simply idles until monitoring resumes. The task is torn down on + /// `deinit`. public func stopGPS() async { - // Idempotent: cheap no-op when nothing is running so this is - // safe to call from teardown paths that may run before any - // `startGPS()` (e.g. scene background, error recovery). - guard let task = ingestTask else { return } - task.cancel() - ingestTask = nil + guard isMonitoring else { return } + isMonitoring = false await locationSource.stop() } @@ -223,9 +240,9 @@ public actor WhereController { locationSource.authorizationUpdates } - /// Whether the GPS ingestion stream is currently attached. Exposed so the - /// view-model can reconcile its tracking flag with reality after launch. + /// Whether GPS monitoring is currently active. Exposed so the view-model + /// can reconcile its tracking flag with reality after launch. public var isTrackingActive: Bool { - ingestTask != nil + isMonitoring } } diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index b699def..72dac76 100644 --- a/Where/WhereCore/Tests/WhereControllerTests.swift +++ b/Where/WhereCore/Tests/WhereControllerTests.swift @@ -174,6 +174,29 @@ struct WhereControllerTests { await controller.stopGPS() } + @Test func trackingResumesAfterPauseWithoutDroppingSamples() async throws { + let (controller, _, source) = try Self.makeController() + + await controller.startGPS() + source.emit(sample(at: "2026-03-15T12:00:00-07:00")) + try await waitUntil { try await controller.yearReport(for: 2026).days.count == 1 } + + // Pause, then resume. A naive implementation cancels the stream + // consumer here, leaving the resumed session iterating a finished + // stream so this second sample would be silently dropped. + await controller.stopGPS() + let pausedActive = await controller.isTrackingActive + #expect(!pausedActive) + await controller.startGPS() + let resumedActive = await controller.isTrackingActive + #expect(resumedActive) + + source.emit(sample(at: "2026-03-16T12:00:00-07:00")) + try await waitUntil { try await controller.yearReport(for: 2026).days.count == 2 } + + await controller.stopGPS() + } + @Test func performThrow_rollsBackEntireTransaction() async throws { let store = try SwiftDataStore.inMemory() let s1 = sample(at: "2026-04-10T08:00:00-07:00") From 5f4e8b0f77ea3f9e67292e50e02ac342b55db58d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 14:10:12 -0700 Subject: [PATCH 21/22] WhereUI: guard stale-year refreshes and surface manual-save failures Addresses three PR review findings in the SwiftUI layer: - WhereModel.refresh() assigned its result unconditionally. Because the model is reentrant while awaiting yearReport, a rapid second select(year:) could let a slower older fetch install its report under the newer year's label. refresh() now captures the requested year and discards results that no longer match the selection. (Deterministic regression test gates the first fetch behind the second.) - Manual day entry always dismissed and reported success: setManualDay / setManualDays swallowed persistence errors into loadState, closing the form as if the backfill worked. These now throw; ManualDayEntryView keeps the form open and shows the error in an inline alert, and the global load state is left untouched. - The Primary tab showed "No travels logged" whenever no region ranked as primary, even when days existed only in .other. It now distinguishes "nothing tracked" from "tracked, but nothing in a headline region" and points the user at the Elsewhere tab. Adds string-catalog entries (with plural variations) and tests for each. Co-authored-by: Cursor --- Where/WhereUI/Sources/Model/WhereModel.swift | 39 ++++--- .../Sources/Preview/PreviewSupport.swift | 25 +++++ .../WhereUI/Sources/Primary/PrimaryView.swift | 17 ++- .../Sources/Resources/Localizable.xcstrings | 56 ++++++++++ .../Sources/Settings/ManualDayEntryView.swift | 41 +++++-- Where/WhereUI/Sources/Shared/Strings.swift | 22 ++++ Where/WhereUI/Tests/RegionRankingTests.swift | 9 ++ Where/WhereUI/Tests/ScreenHostingTests.swift | 7 ++ Where/WhereUI/Tests/StringsTests.swift | 16 +++ Where/WhereUI/Tests/Support/TestStore.swift | 100 ++++++++++++++++++ .../Tests/WhereModelRefreshTests.swift | 86 +++++++++++++++ 11 files changed, 392 insertions(+), 26 deletions(-) create mode 100644 Where/WhereUI/Tests/Support/TestStore.swift create mode 100644 Where/WhereUI/Tests/WhereModelRefreshTests.swift diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index a9b045a..ac53f34 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -184,33 +184,42 @@ public final class WhereModel { public func refresh() async { guard let controller else { return } + // Capture the year this fetch is for. `WhereModel` is reentrant while + // awaiting `yearReport`, so a rapid second `select(year:)` can start a + // newer fetch that finishes first; without this guard a slower older + // fetch could install its report under the newer year's label. + let requestedYear = selectedYear loadState = .loading do { - report = try await controller.yearReport(for: selectedYear) + let report = try await controller.yearReport(for: requestedYear) + guard requestedYear == selectedYear else { return } + self.report = report loadState = .loaded } catch { + guard requestedYear == selectedYear else { return } loadState = .failed(error.localizedDescription) } } - public func setManualDay(date: Date, regions: Set) async { + /// Persist a single manual day. Throws on persistence failure so the + /// caller (the entry form) can keep itself open and show the error inline + /// instead of dismissing as if the save succeeded. + public func setManualDay(date: Date, regions: Set) async throws { guard let controller else { return } - do { - try await controller.addManualDay(date: date, regions: regions) - await refresh() - } catch { - loadState = .failed(error.localizedDescription) - } + try await controller.addManualDay(date: date, regions: regions) + await refresh() } - public func setManualDays(from start: Date, through end: Date, regions: Set) async { + /// Persist a manual day range. Throws on persistence failure (see + /// `setManualDay(date:regions:)`). + public func setManualDays( + from start: Date, + through end: Date, + regions: Set, + ) async throws { guard let controller else { return } - do { - try await controller.addManualDays(from: start, through: end, regions: regions) - await refresh() - } catch { - loadState = .failed(error.localizedDescription) - } + try await controller.addManualDays(from: start, through: end, regions: regions) + await refresh() } /// Explicitly (re)request location access, e.g. from the "Grant location diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index ceccc3b..a1bab68 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -71,5 +71,30 @@ selectedYear: year, ) } + + /// A model whose only tracked days are in `.other` β€” there's data, but + /// nothing ranks as "primary". Exercises the Primary tab's distinct + /// "nothing in your headline spots" state. + @MainActor + public static func elsewhereOnlyModel() -> WhereModel { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + let startOfYear = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! + let days = (0 ..< 9).map { offset in + DayPresence( + date: calendar.date(byAdding: .day, value: offset, to: startOfYear)!, + regions: [.other], + ) + } + let controller = WhereController( + store: try! SwiftDataStore.inMemory(), + locationSource: ScriptedLocationSource(), + ) + return WhereModel( + controller: controller, + report: YearReport(year: year, days: days, totals: [.other: days.count]), + selectedYear: year, + ) + } } #endif diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index fd5545b..ae578f3 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -49,7 +49,14 @@ struct PrimaryView: View { } default: if model.ranking.primary.isEmpty { - emptyState + // Distinguish "nothing tracked at all" from "tracked days + // exist, but only in non-headline regions" (e.g. all in + // `.other`) β€” otherwise the latter wrongly reads as empty. + if model.trackedDayCount == 0 { + emptyState + } else { + elsewhereOnlyState + } } else { content } @@ -99,6 +106,14 @@ struct PrimaryView: View { } } + private var elsewhereOnlyState: some View { + ContentUnavailableView { + Label(Strings.primaryElsewhereOnlyTitle, systemImage: "globe.americas") + } description: { + Text(Strings.primaryElsewhereOnlyDescription(count: model.trackedDayCount)) + } + } + /// Playful rank labels for the top regions. private func caption(forRank rank: Int) -> String? { switch rank { diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 7a3fb31..dd162f4 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -57,6 +57,17 @@ } } }, + "common.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, "common.regionDays.accessibility" : { "extractionState" : "manual", "localizations" : { @@ -179,6 +190,17 @@ } } }, + "manual.saveError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't save that day" + } + } + } + }, "manual.singleDay.footer" : { "extractionState" : "manual", "localizations" : { @@ -234,6 +256,40 @@ } } }, + "primary.elsewhereOnly.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day logged this year, but none in a headline spot yet. Peek at the Elsewhere tab." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab." + } + } + } + } + } + } + }, + "primary.elsewhereOnly.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing in your headline spots" + } + } + } + }, "primary.empty.description" : { "extractionState" : "manual", "localizations" : { diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 77fce36..813c3c7 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -30,6 +30,7 @@ struct ManualDayEntryView: View { @State private var endDate = Date() @State private var selectedRegions: Set = [] @State private var isSaving = false + @State private var saveError: String? private var dayCount: Int { let calendar = Calendar.current @@ -90,6 +91,19 @@ struct ManualDayEntryView: View { .disabled(!canSave) } } + .alert( + Strings.manualSaveErrorTitle, + isPresented: Binding( + get: { saveError != nil }, + set: { if !$0 { saveError = nil } }, + ), + ) { + Button(Strings.commonOK, role: .cancel) {} + } message: { + if let saveError { + Text(saveError) + } + } } @ViewBuilder @@ -142,18 +156,25 @@ struct ManualDayEntryView: View { private func save() { isSaving = true + saveError = nil Task { - switch mode { - case .singleDay: - await model.setManualDay(date: startDate, regions: selectedRegions) - case .range: - await model.setManualDays( - from: startDate, - through: endDate, - regions: selectedRegions, - ) + do { + switch mode { + case .singleDay: + try await model.setManualDay(date: startDate, regions: selectedRegions) + case .range: + try await model.setManualDays( + from: startDate, + through: endDate, + regions: selectedRegions, + ) + } + dismiss() + } catch { + // Keep the form up so the user can retry; the save didn't land. + saveError = error.localizedDescription + isSaving = false } - dismiss() } } } diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 0b28376..8edb1c5 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -28,6 +28,10 @@ enum Strings { localized("common.loadError.title") } + static var commonOK: String { + localized("common.ok") + } + /// "1 day" / "5 days" β€” with the count rendered. static func dayCount(_ count: Int) -> String { String(localized: "common.dayCount", defaultValue: "\(count) days", bundle: .module) @@ -64,6 +68,20 @@ enum Strings { localized("primary.empty.description") } + static var primaryElsewhereOnlyTitle: String { + localized("primary.elsewhereOnly.title") + } + + /// Shown when there's tracked data, but none of it lands in a primary + /// region β€” points the user at the Elsewhere tab. + static func primaryElsewhereOnlyDescription(count: Int) -> String { + String( + localized: "primary.elsewhereOnly.description", + defaultValue: "\(count) days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + bundle: .module, + ) + } + static var primaryCaptionHomeBase: String { localized("primary.caption.homeBase") } @@ -278,6 +296,10 @@ enum Strings { localized("manual.save") } + static var manualSaveErrorTitle: String { + localized("manual.saveError.title") + } + static func manualRangeFooter(count: Int) -> String { String( localized: "manual.range.footer", diff --git a/Where/WhereUI/Tests/RegionRankingTests.swift b/Where/WhereUI/Tests/RegionRankingTests.swift index 94aa026..a160770 100644 --- a/Where/WhereUI/Tests/RegionRankingTests.swift +++ b/Where/WhereUI/Tests/RegionRankingTests.swift @@ -27,6 +27,15 @@ struct RegionRankingTests { #expect(ranking.secondary.map(\.region) == [.other]) } + @Test func onlyOtherDaysLeavePrimaryEmptyButRankingNonEmpty() { + // Drives the Primary tab's "data exists, but not in a headline region" + // empty state: `primary` is empty yet there's real tracked data. + let ranking = RegionRanking(report: report([.other: 12])) + #expect(ranking.primary.isEmpty) + #expect(ranking.secondary.map(\.region) == [.other]) + #expect(!ranking.isEmpty) + } + @Test func zeroDayRegionsAreDropped() { let ranking = RegionRanking(report: report([ .california: 5, diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 243d1ca..0070c25 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -28,6 +28,13 @@ struct ScreenHostingTests { } } + @Test func primaryViewHostsWithElsewhereOnlyData() throws { + let model = PreviewSupport.elsewhereOnlyModel() + try show(UIHostingController(rootView: PrimaryView().environment(model))) { hosted in + #expect(hosted.view != nil) + } + } + @Test func presenceTimelineViewHostsWithData() throws { let model = PreviewSupport.loadedModel() try show(UIHostingController(rootView: PresenceTimelineView() diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift index d4956c2..075353b 100644 --- a/Where/WhereUI/Tests/StringsTests.swift +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -9,6 +9,22 @@ struct StringsTests { #expect(Strings.primaryTitle == "Where") #expect(Strings.tabElsewhere == "Elsewhere") #expect(Strings.loadErrorTitle == "Couldn't load your year") + #expect(Strings.commonOK == "OK") + #expect(Strings.manualSaveErrorTitle == "Couldn't save that day") + #expect(Strings.primaryElsewhereOnlyTitle == "Nothing in your headline spots") + } + + @Test func elsewhereOnlyDescriptionUsesPluralVariations() { + #expect( + Strings.primaryElsewhereOnlyDescription(count: 1) + == + "1 day logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) + #expect( + Strings.primaryElsewhereOnlyDescription(count: 9) + == + "9 days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) } @Test func dayCountUsesPluralVariations() { diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift new file mode 100644 index 0000000..4db61b0 --- /dev/null +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -0,0 +1,100 @@ +import Foundation +import WhereCore + +/// Thrown by `TestStore.setManualDay` when failure injection is enabled. +struct ManualSaveFailure: Error, Equatable {} + +/// Test `WhereStore` that forwards to an in-memory `SwiftDataStore` but adds +/// two hooks the view-model tests need: +/// +/// - `enableFirstSamplesGate()` suspends the first `samples(in:)` call until +/// the test releases it, so two `refresh()`es can be forced to complete out +/// of order (the stale-year race). +/// - `failManualDays()` makes `setManualDay` throw, so manual-entry error +/// handling is exercisable without a real persistence fault. +/// +/// Everything else forwards to the backing store so reads stay deterministic. +actor TestStore: WhereStore { + private let backing: SwiftDataStore + + private var gateFirstSamplesCall = false + private var firstSamplesSeen = false + private var gate: CheckedContinuation? + private var arrival: CheckedContinuation? + + private var shouldFailManualDay = false + + init() throws { + backing = try SwiftDataStore.inMemory() + } + + // MARK: - Test controls + + func enableFirstSamplesGate() { + gateFirstSamplesCall = true + } + + /// Suspends until the gated first `samples(in:)` call has arrived. + func awaitFirstSamplesCall() async { + guard !firstSamplesSeen else { return } + await withCheckedContinuation { arrival = $0 } + } + + func releaseFirstSamplesCall() { + gate?.resume() + gate = nil + } + + func failManualDays() { + shouldFailManualDay = true + } + + // MARK: - WhereStore + + func perform(_ block: @Sendable () async throws -> T) async throws -> T { + try await backing.perform(block) + } + + func add(sample: LocationSample) async throws { + try await backing.add(sample: sample) + } + + func samples(in interval: DateInterval) async throws -> [LocationSample] { + if gateFirstSamplesCall, !firstSamplesSeen { + firstSamplesSeen = true + arrival?.resume() + arrival = nil + await withCheckedContinuation { gate = $0 } + } + return try await backing.samples(in: interval) + } + + func allSamples() async throws -> [LocationSample] { + try await backing.allSamples() + } + + func write(evidence: Evidence, blob: Data?) async throws { + try await backing.write(evidence: evidence, blob: blob) + } + + func evidence(in interval: DateInterval) async throws -> [Evidence] { + try await backing.evidence(in: interval) + } + + func evidenceBlob(for id: UUID) async throws -> Data? { + try await backing.evidenceBlob(for: id) + } + + func setManualDay(_ day: DayPresence) async throws { + if shouldFailManualDay { throw ManualSaveFailure() } + try await backing.setManualDay(day) + } + + func manualDays(in interval: DateInterval) async throws -> [DayPresence] { + try await backing.manualDays(in: interval) + } + + func clear(in interval: DateInterval) async throws { + try await backing.clear(in: interval) + } +} diff --git a/Where/WhereUI/Tests/WhereModelRefreshTests.swift b/Where/WhereUI/Tests/WhereModelRefreshTests.swift new file mode 100644 index 0000000..e73a019 --- /dev/null +++ b/Where/WhereUI/Tests/WhereModelRefreshTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +import WhereCore +@testable import WhereUI + +/// Covers `WhereModel`'s refresh/save error handling that the PR review bots +/// flagged: out-of-order year fetches must not install stale data, and a +/// failed manual save must surface as an error rather than silently +/// "succeeding". +@MainActor +struct WhereModelRefreshTests { + private func date(year: Int, month: Int, day: Int) -> Date { + Calendar.current.date( + from: DateComponents(year: year, month: month, day: day, hour: 12), + )! + } + + @Test func staleYearFetchDoesNotOverwriteNewerSelection() async throws { + let store = try TestStore() + let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + + // Seed each year with a distinct region so we can tell which report won. + try await controller.addManualDay( + date: date(year: 2024, month: 3, day: 1), + regions: [.newYork], + ) + try await controller.addManualDay( + date: date(year: 2026, month: 3, day: 1), + regions: [.california], + ) + + let model = WhereModel(controller: controller, selectedYear: 2026) + await store.enableFirstSamplesGate() + + // Start the 2024 fetch; it suspends inside the gated `samples(in:)`. + let stale = Task { await model.select(year: 2024) } + await store.awaitFirstSamplesCall() + + // The 2026 fetch runs to completion while 2024 is still in flight. + await model.select(year: 2026) + #expect(model.report?.year == 2026) + + // Now let the slower 2024 fetch finish β€” it must be discarded. + await store.releaseFirstSamplesCall() + await stale.value + + #expect(model.selectedYear == 2026) + #expect(model.report?.year == 2026) + #expect(model.report?.totals[.california] == 1) + #expect(model.report?.totals[.newYork] == nil) + #expect(model.loadState == .loaded) + } + + @Test func failedManualSaveThrowsAndLeavesLoadStateAlone() async throws { + let store = try TestStore() + await store.failManualDays() + let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + let model = WhereModel(controller: controller, selectedYear: 2026) + + await #expect(throws: ManualSaveFailure.self) { + try await model.setManualDay( + date: self.date(year: 2026, month: 1, day: 2), + regions: [.california], + ) + } + + // A failed save must not flip the whole screen into the error state; + // the form surfaces the error inline instead. + #expect(model.loadState == .idle) + } + + @Test func failedManualRangeSaveThrows() async throws { + let store = try TestStore() + await store.failManualDays() + let controller = WhereController(store: store, locationSource: ScriptedLocationSource()) + let model = WhereModel(controller: controller, selectedYear: 2026) + + await #expect(throws: ManualSaveFailure.self) { + try await model.setManualDays( + from: self.date(year: 2026, month: 1, day: 2), + through: self.date(year: 2026, month: 1, day: 4), + regions: [.california], + ) + } + } +} From d833a7547790531b415f553985a3b57882ad829a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 May 2026 14:15:21 -0700 Subject: [PATCH 22/22] Add explicit schemes for WhereTests and WhereUITests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tuist's autogeneration emits standalone schemes for StuffCoreTests and WhereCoreTests but, for this graph, not for WhereTests or WhereUITests β€” they were only reachable via the aggregate Stuff-Workspace scheme, which builds and tests everything. Declaring the two schemes explicitly lets `tuist test WhereTests` / `tuist test WhereUITests` run a single bundle. Tooling-only change; no production code affected. Co-authored-by: Cursor --- Project.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Project.swift b/Project.swift index 5f9b59b..5225ab8 100644 --- a/Project.swift +++ b/Project.swift @@ -33,6 +33,16 @@ func unitTests( ) } +/// A shared scheme that builds and tests a single unit-test bundle. +func testScheme(name: String) -> Scheme { + .scheme( + name: name, + shared: true, + buildAction: .buildAction(targets: ["\(name)"]), + testAction: .targets(["\(name)"]), + ) +} + let project = Project( name: "Stuff", options: .options( @@ -119,4 +129,13 @@ let project = Project( sources: ["Where/WhereUI/Tests/**"], ), ], + // Tuist's autogeneration doesn't emit standalone schemes for these two + // unit-test bundles (only the aggregate `Stuff-Workspace` scheme covers + // them), so declare them explicitly. This lets `tuist test WhereTests` / + // `tuist test WhereUITests` target a single bundle without building the + // whole workspace. + schemes: [ + testScheme(name: "WhereTests"), + testScheme(name: "WhereUITests"), + ], )