Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c9af474
Add public SwiftDataStore.make(storage:) factory for app wiring
kyleve May 29, 2026
13cc497
Add WhereUI model layer: WhereModel, RegionRanking, RegionStyle, prev…
kyleve May 29, 2026
a6e6170
Replace RootView with a Liquid Glass TabView
kyleve May 29, 2026
b4489e2
Build Primary tab with Liquid Glass region cards
kyleve May 29, 2026
a2e725d
Build Elsewhere tab with compact region cards
kyleve May 29, 2026
6e95e0e
Build Settings tab: manual entry, GPS controls, erase-year
kyleve May 29, 2026
de68f9e
Add location usage descriptions to the Where app Info.plist
kyleve May 29, 2026
2728c00
Test RegionRanking split and host the top-level screens
kyleve May 29, 2026
ea19c17
Require a feature branch before committing plan work
kyleve May 29, 2026
de8059a
Support backfilling a date range in manual day entry
kyleve May 29, 2026
ec3073f
Add a stays timeline to the Primary tab
kyleve May 29, 2026
9002648
Extract calendar-day enumeration into a tested Date extension
kyleve May 29, 2026
0f14995
Gate tracking on permission and clear stale report on year switch
kyleve May 29, 2026
9cf516c
Derive region-card year length from the calendar
kyleve May 29, 2026
a8da481
Centralize WhereUI layout literals in UIConstants
kyleve May 29, 2026
492b400
Route all WhereUI strings through a string catalog
kyleve May 29, 2026
bb36ee3
Expose location authorization status from WhereCore
kyleve May 29, 2026
9df9b1c
Reconcile background tracking and show location status in Settings
kyleve May 29, 2026
f1c0871
Wire location startup to app launch for background relaunch
kyleve May 29, 2026
6d5bbca
WhereCore: make GPS restartable and coalesce permission requests
kyleve May 29, 2026
5f4e8b0
WhereUI: guard stale-year refreshes and surface manual-save failures
kyleve May 29, 2026
d833a75
Add explicit schemes for WhereTests and WhereUITests
kyleve May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <descriptive-name>` 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.
Expand Down
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ let package = Package(
.target(name: "WhereCore"),
],
path: "Where/WhereUI/Sources",
resources: [
.process("Resources"),
],
),
.target(
name: "WhereTesting",
Expand Down
25 changes: 25 additions & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -50,6 +60,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/**"],
Expand Down Expand Up @@ -113,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"),
],
)
35 changes: 35 additions & 0 deletions Where/Where/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 24 in Where/Where/Sources/AppDelegate.swift

View workflow job for this annotation

GitHub Actions / Build & Test

'location' was deprecated in iOS 26.0: Adopt CLLocationUpdate or CLMonitor, or use CLLocationManagerDelegate from CoreLocation to handle expected location events after scene connection.
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
}
}
4 changes: 3 additions & 1 deletion Where/Where/Sources/WhereApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
25 changes: 25 additions & 0 deletions Where/WhereCore/Sources/Date+CalendarDays.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
116 changes: 84 additions & 32 deletions Where/WhereCore/Sources/Location/CoreLocationSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ import Foundation
@MainActor
public final class CoreLocationSource: NSObject, LocationSource {
public nonisolated let sampleStream: AsyncStream<LocationSample>
public nonisolated let authorizationUpdates: AsyncStream<LocationAuthorizationStatus>

private let manager: CLLocationManager
private nonisolated let sampleContinuation: AsyncStream<LocationSample>.Continuation
private nonisolated let authorizationContinuation: AsyncStream<LocationAuthorizationStatus>
.Continuation

/// Continuation for the in-flight `requestPermission()` call. Set
/// when the call begins and consumed on the next
/// `locationManagerDidChangeAuthorization` callback.
private var pendingPermissionContinuation: CheckedContinuation<Void, Error>?
/// 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<Void, Error>] = []

override public init() {
// The "create stream, capture its continuation" two-step is
Expand All @@ -49,6 +55,12 @@ public final class CoreLocationSource: NSObject, LocationSource {
sampleStream = AsyncStream { sampleCont = $0 }
sampleContinuation = sampleCont

var authCont: AsyncStream<LocationAuthorizationStatus>.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
Expand All @@ -64,65 +76,102 @@ 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
case .denied:
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.
// 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
pendingPermissionContinuations.append(continuation)
if pendingPermissionContinuations.count == 1 {
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()
}
}

/// 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
guard !pendingPermissionContinuations.isEmpty else { return }
switch status {
case .authorizedAlways:
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 .authorizedAlways, .authorizedWhenInUse:
resumePendingPermission(with: .success(()))
case .denied:
continuation.resume(
throwing: LocationPermissionDeniedError(reason: .denied),
resumePendingPermission(
with: .failure(LocationPermissionDeniedError(reason: .denied)),
)
case .restricted:
continuation.resume(
throwing: LocationPermissionDeniedError(reason: .restricted),
resumePendingPermission(
with: .failure(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 continuations pending.
break
@unknown default:
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<Void, Error>) {
let waiters = pendingPermissionContinuations
pendingPermissionContinuations.removeAll()
for waiter in waiters {
waiter.resume(with: result)
}
}

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
}
}
}

extension CoreLocationSource: CLLocationManagerDelegate {
Expand Down Expand Up @@ -173,6 +222,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)
}
Expand Down
31 changes: 31 additions & 0 deletions Where/WhereCore/Sources/Location/LocationAuthorizationStatus.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading