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. 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/Project.swift b/Project.swift index 6f9ba0c..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( @@ -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/**"], @@ -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"), + ], ) 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/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/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index 862f07f..24c55a6 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -29,14 +29,20 @@ 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 - /// `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 @@ -49,6 +55,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 +76,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 +88,31 @@ 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. + // 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() } } @@ -91,38 +120,58 @@ 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 + 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) { + 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 { @@ -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) } 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/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/Sources/WhereController.swift b/Where/WhereCore/Sources/WhereController.swift index 7f59e99..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. @@ -69,6 +75,29 @@ 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 { + // `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 { + try await store.setManualDay(DayPresence(date: day, regions: regions)) + } + } + } + // MARK: - Evidence public func addEvidence(_ evidence: Evidence, blob: Data? = nil) async throws { @@ -104,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 { @@ -175,17 +213,36 @@ 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() } 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 GPS monitoring is currently active. Exposed so the view-model + /// can reconcile its tracking flag with reality after launch. + public var isTrackingActive: Bool { + isMonitoring + } } 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))) + } +} 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) + } +} diff --git a/Where/WhereCore/Tests/WhereControllerTests.swift b/Where/WhereCore/Tests/WhereControllerTests.swift index a5083c3..72dac76 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( @@ -133,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") 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 { 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/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..ac53f34 --- /dev/null +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -0,0 +1,276 @@ +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 + + /// 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. + 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 + } + + /// 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()) + } + + 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 + /// 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, + defaults: UserDefaults = .standard, + ) { + self.controller = controller + self.report = report + self.selectedYear = selectedYear + self.defaults = defaults + loadState = report == nil ? .idle : .loaded + } + + /// 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() + 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 + // 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() + } + + 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 { + 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) + } + } + + /// 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 } + try await controller.addManualDay(date: date, regions: regions) + await refresh() + } + + /// 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 } + try await controller.addManualDays(from: start, through: end, regions: regions) + await refresh() + } + + /// 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 { + // `.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. 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 + } + await syncAuthorization() + await reconcileTracking() + } + + public func stopTracking() async { + guard let controller else { return } + wantsTracking = false + 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..a1bab68 --- /dev/null +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -0,0 +1,100 @@ +#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, + ) + } + + /// 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/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift new file mode 100644 index 0000000..cfa4db7 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -0,0 +1,104 @@ +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(Strings.timelineEmptyTitle, systemImage: "calendar.day.timeline.left") + } description: { + Text(Strings.timelineEmptyDescription) + } + } else { + List(stints) { stint in + StintRow(stint: stint) + } + } + } + .navigationTitle(Strings.timelineTitle(year: model.selectedYear)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.timelineDone) { dismiss() } + } + } + } + } +} + +/// 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: UIConstants.Spacings.large) { + Capsule() + .fill(style.tint.gradient) + .frame( + width: UIConstants.Size.timelineAccentWidth, + height: UIConstants.Size.timelineAccentHeight, + ) + + Text(style.emoji) + .font(.title3) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { + Text(stint.region.localizedName) + .font(.headline) + Text(dateRange) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: UIConstants.Spacings.medium) + + Text(Strings.dayCount(stint.dayCount)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, UIConstants.Spacings.xSmall) + .accessibilityElement(children: .combine) + .accessibilityLabel( + Strings.timelineRowAccessibility( + region: stint.region.localizedName, + range: dateRange, + days: stint.dayCount, + ), + ) + } + + 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 new file mode 100644 index 0000000..ae578f3 --- /dev/null +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WhereCore + +/// 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 + + @State private var showingTimeline = false + + var body: some View { + NavigationStack { + screen + .navigationTitle(Strings.primaryTitle) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingTimeline = true + } label: { + Label( + Strings.primaryTimeline, + systemImage: "calendar.day.timeline.left", + ) + } + .accessibilityIdentifier("where_timeline_button") + } + ToolbarItem(placement: .topBarTrailing) { + YearSelector() + } + } + .sheet(isPresented: $showingTimeline) { + PresenceTimelineView() + .environment(model) + } + } + } + + @ViewBuilder + private var screen: some View { + switch model.loadState { + case .loading where model.report == nil: + ProgressView(Strings.primaryLoading) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(message): + ContentUnavailableView { + Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + } description: { + Text(message) + } + default: + if model.ranking.primary.isEmpty { + // 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 + } + } + } + + private var content: some View { + ScrollView { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxxLarge) { + header + GlassEffectContainer(spacing: UIConstants.Spacings.xxLarge) { + VStack(spacing: UIConstants.Spacings.xxLarge) { + ForEach( + Array(model.ranking.primary.enumerated()), + id: \.element.id, + ) { index, item in + RegionSummaryCard( + regionDays: item, + caption: caption(forRank: index), + yearLength: model.daysInSelectedYear, + ) + } + } + } + } + .padding() + } + .accessibilityIdentifier("where_root_title") + } + + private var header: some View { + VStack(alignment: .leading, spacing: UIConstants.Spacings.xSmall) { + Text(Strings.primaryHeaderTitle(year: model.selectedYear)) + .font(.largeTitle.bold()) + Text(Strings.primaryHeaderSubtitle(count: model.trackedDayCount)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var emptyState: some View { + ContentUnavailableView { + Label(Strings.primaryEmptyTitle(year: model.selectedYear), systemImage: "map") + } description: { + Text(Strings.primaryEmptyDescription) + } + } + + 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 { + case 0: Strings.primaryCaptionHomeBase + case 1: Strings.primaryCaptionSecondHome + 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..846673b --- /dev/null +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -0,0 +1,118 @@ +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 + + /// 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 { + 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 ? UIConstants.Spacings.regular : UIConstants.Spacings.xxLarge, + ) { + HStack(spacing: UIConstants.Spacings.large) { + Text(style.emoji) + .font(compact ? .title2 : .largeTitle) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { + 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: UIConstants.Spacings.small) { + Text(regionDays.days, format: .number) + .font( + compact + ? .system(.title, design: .rounded, weight: .bold) + : .system( + size: UIConstants.Size.heroNumberFontSize, + weight: .bold, + design: .rounded, + ), + ) + .contentTransition(.numericText()) + .foregroundStyle(style.tint) + Text(Strings.dayUnit(regionDays.days)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + + Capsule() + .fill(.quaternary) + .frame(height: UIConstants.Size.progressBarHeight) + .overlay(alignment: .leading) { + GeometryReader { proxy in + Capsule() + .fill(style.tint.gradient) + .frame(width: proxy.size.width * fraction) + } + } + .frame(height: UIConstants.Size.progressBarHeight) + .accessibilityHidden(true) + } + .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 ? UIConstants.CornerRadius.compactCard : UIConstants + .CornerRadius.card, + style: .continuous, + ), + ) + .accessibilityElement(children: .combine) + .accessibilityLabel( + Strings.regionDaysAccessibility( + region: regionDays.region.localizedName, + days: regionDays.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/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings new file mode 100644 index 0000000..dd162f4 --- /dev/null +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,791 @@ +{ + "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.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "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.saveError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't save that day" + } + } + } + }, + "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.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" : { + "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.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" : { + "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 626ffa1..bd266ab 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -1,11 +1,46 @@ 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 { - public init() {} + @State private var model: WhereModel + + /// 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 { - Text("Where") - .accessibilityIdentifier("where_root_title") + TabView { + Tab(Strings.tabPrimary, systemImage: "star.fill") { + PrimaryView() + } + + Tab(Strings.tabElsewhere, systemImage: "globe.americas.fill") { + SecondaryView() + } + + Tab(Strings.tabSettings, 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..0496e24 --- /dev/null +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import WhereCore + +/// 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 { + screen + .navigationTitle(Strings.secondaryTitle) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + YearSelector() + } + } + } + } + + @ViewBuilder + private var screen: some View { + switch model.loadState { + case .loading where model.report == nil: + ProgressView(Strings.secondaryLoading) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(message): + ContentUnavailableView { + Label(Strings.loadErrorTitle, 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: UIConstants.Spacings.xLarge) { + Text(Strings.secondaryHeader(year: model.selectedYear)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + GlassEffectContainer(spacing: UIConstants.Spacings.large) { + VStack(spacing: UIConstants.Spacings.large) { + ForEach(model.ranking.secondary) { item in + RegionSummaryCard( + regionDays: item, + caption: caption(for: item), + compact: true, + yearLength: model.daysInSelectedYear, + ) + } + } + } + } + .padding() + } + } + + private var emptyState: some View { + ContentUnavailableView { + Label(Strings.secondaryEmptyTitle, systemImage: "globe.americas") + } description: { + Text(Strings.secondaryEmptyDescription) + } + } + + /// Light whimsy for the briefest stays. + private func caption(for item: RegionDays) -> String? { + item.days <= 3 ? Strings.secondaryCaptionPassingThrough : nil + } +} + +#if DEBUG + #Preview("Loaded") { + SecondaryView() + .environment(PreviewSupport.loadedModel()) + } + + #Preview("Empty") { + SecondaryView() + .environment(PreviewSupport.emptyModel()) + } +#endif 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/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift new file mode 100644 index 0000000..813c3c7 --- /dev/null +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -0,0 +1,189 @@ +import SwiftUI +import WhereCore + +/// 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 + + private enum EntryMode: Hashable, CaseIterable, Identifiable { + case singleDay + case range + + var id: Self { + self + } + + var title: String { + switch self { + case .singleDay: Strings.manualModeSingleDay + case .range: Strings.manualModeRange + } + } + } + + @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 + @State private var saveError: String? + + 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 { + Picker(Strings.manualEntryPickerLabel, selection: $mode) { + ForEach(EntryMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("where_manual_mode") + + datePickers + } footer: { + Text(dateFooter) + } + + Section { + ForEach(Region.allCases, id: \.self) { region in + Toggle(isOn: binding(for: region)) { + Label { + Text(region.localizedName) + } icon: { + Text(region.style.emoji) + } + } + } + } header: { + Text(Strings.manualRegionsHeader) + } footer: { + Text(Strings.manualRegionsFooter) + } + } + .navigationTitle(Strings.manualTitle) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: startDate) { _, newValue in + if endDate < newValue { endDate = newValue } + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.manualSave) { save() } + .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 + private var datePickers: some View { + switch mode { + case .singleDay: + DatePicker( + Strings.manualDay, + selection: $startDate, + in: ...Date(), + displayedComponents: .date, + ) + case .range: + DatePicker( + Strings.manualFrom, + selection: $startDate, + in: ...Date(), + displayedComponents: .date, + ) + DatePicker( + Strings.manualThrough, + selection: $endDate, + in: startDate ... Date(), + displayedComponents: .date, + ) + } + } + + private var dateFooter: String { + switch mode { + case .singleDay: + Strings.manualSingleDayFooter + case .range: + Strings.manualRangeFooter(count: dayCount) + } + } + + 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 + saveError = nil + Task { + 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 + } + } + } +} + +#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 new file mode 100644 index 0000000..e6b382b --- /dev/null +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -0,0 +1,149 @@ +import SwiftUI +import UIKit +import WhereCore + +/// 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 { + Form { + trackingSection + manualEntrySection + dataSection + } + .navigationTitle(Strings.settingsTitle) + .alert(Strings.settingsPermissionAlertTitle, isPresented: $model.permissionDenied) { + Button(Strings.settingsPermissionAlertOpenSettings) { openSystemSettings() } + Button(Strings.settingsPermissionAlertNotNow, role: .cancel) {} + } message: { + Text(Strings.settingsPermissionAlertMessage) + } + } + } + + private var trackingSection: some View { + Section { + LocationStatusRow(status: model.authorizationStatus, isTracking: model.isTracking) + + Toggle(isOn: trackingBinding) { + Label(Strings.settingsLocationToggle, systemImage: "location.fill") + } + + 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) + } footer: { + Text(Strings.settingsLocationFooter) + } + } + + /// 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 { + ManualDayEntryView() + } label: { + Label(Strings.settingsManualLink, systemImage: "calendar.badge.plus") + } + } header: { + Text(Strings.settingsManualHeader) + } footer: { + Text(Strings.settingsManualFooter) + } + } + + 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(Strings.settingsDataCancel, role: .cancel) {} + } message: { + Text(Strings.settingsDataConfirmMessage(year: model.selectedYear)) + } + } header: { + Text(Strings.settingsDataHeader) + } footer: { + Text(Strings.settingsDataFooter(year: model.selectedYear)) + } + } + + private var eraseTitle: String { + Strings.settingsDataErase(year: model.selectedYear) + } + + 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 diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift new file mode 100644 index 0000000..8edb1c5 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -0,0 +1,351 @@ +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") + } + + 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) + } + + /// "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 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") + } + + 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 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") + } + + 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 var manualSaveErrorTitle: String { + localized("manual.saveError.title") + } + + 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/UIConstants.swift b/Where/WhereUI/Sources/Shared/UIConstants.swift new file mode 100644 index 0000000..0c7b538 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/UIConstants.swift @@ -0,0 +1,39 @@ +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 + static let statusIconWidth: CGFloat = 28 + } +} diff --git a/Where/WhereUI/Sources/Shared/YearSelector.swift b/Where/WhereUI/Sources/Shared/YearSelector.swift new file mode 100644 index 0000000..071fea2 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/YearSelector.swift @@ -0,0 +1,38 @@ +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(yearText(year)) } icon: { Image(systemName: "checkmark") } + } else { + Text(yearText(year)) + } + } + } + } label: { + 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/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/RegionRankingTests.swift b/Where/WhereUI/Tests/RegionRankingTests.swift new file mode 100644 index 0000000..a160770 --- /dev/null +++ b/Where/WhereUI/Tests/RegionRankingTests.swift @@ -0,0 +1,80 @@ +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 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, + .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..0070c25 --- /dev/null +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -0,0 +1,46 @@ +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) + } + } + + @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() + .environment(model))) + { hosted in + #expect(hosted.view != nil) + } + } +} diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift new file mode 100644 index 0000000..075353b --- /dev/null +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -0,0 +1,50 @@ +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") + #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() { + #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") + } +} 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], + ) + } + } +} 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") + } +}