From 3b0f4ce3a707429e52e948f8425bd43dc49809ab Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 19:08:28 -0700 Subject: [PATCH 01/14] Add region-picking onboarding + per-region customization Build the deferred onboarding step that lets the user pick up to 5 US primary regions (map or searchable list, via a top segmented control) and customize each region's color/emoji/icon from predefined lists. - WhereCore: RegionColorToken / RegionAppearance / PrimaryRegion value types; SDTrackedRegion carries appearance + pick order; WhereStore primaryRegions() / setPrimaryRegion(). Picks are the tracked-region set. - WhereUI: RegionAppearanceCatalog + RegionStyleRegistry make RegionStyle data-driven (seeded by WhereSession on launch/changes, and by the widget snapshot in the widget process); PrimaryRegionSelectionModel, RegionPickerView, RegionCustomizeView, RegionsSettingsView. - OnboardingView is now an intro -> pick -> customize -> location state machine that commits picks before finishing; Settings gains a Regions editor. WidgetSnapshot carries appearances so widgets render user looks. - Tests: store round-trip/ordering, selection-model cap, registry resolution, widget appearance; RegionPickerStyle stylesheet group + docs. --- Where/WhereCore/AGENTS.md | 6 + Where/WhereCore/README.md | 9 +- .../Sources/Persistence/SwiftDataStore.swift | 109 +++++++- .../Sources/Persistence/WhereStore.swift | 33 +++ .../Sources/Regions/PrimaryRegion.swift | 23 ++ .../Sources/Regions/RegionAppearance.swift | 38 +++ Where/WhereCore/Sources/WhereServices.swift | 27 ++ .../Sources/Widgets/WidgetDataReader.swift | 37 ++- .../Tests/PrimaryRegionStoreTests.swift | 89 ++++++ .../Tests/RegionAppearanceTests.swift | 30 +++ .../Tests/WidgetDataReaderTests.swift | 31 +++ Where/WhereUI/AGENTS.md | 9 +- Where/WhereUI/README.md | 14 +- .../WhereUI/Sources/Launch/WhereLaunch.swift | 2 + .../Model/RegionAppearanceCatalog.swift | 148 ++++++++++ Where/WhereUI/Sources/Model/RegionStyle.swift | 82 ++---- .../Sources/Model/RegionStyleRegistry.swift | 45 ++++ .../WhereUI/Sources/Model/WhereSession.swift | 36 +++ .../Sources/Onboarding/OnboardingView.swift | 187 ++++++++++--- .../Sources/Preview/PreviewSupport.swift | 32 +++ .../Sources/Primary/RegionSummaryCard.swift | 8 +- .../Regions/PrimaryRegionSelectionModel.swift | 137 ++++++++++ .../Sources/Regions/RegionCustomizeView.swift | 253 ++++++++++++++++++ .../Sources/Regions/RegionPickerView.swift | 246 +++++++++++++++++ .../Sources/Regions/RegionsSettingsView.swift | 112 ++++++++ .../Sources/Settings/SettingsView.swift | 21 ++ .../Sources/Shared/Coordinate+MapKit.swift | 12 + Where/WhereUI/Sources/Shared/Strings.swift | 170 ++++++++++++ .../Sources/Shared/WhereStylesheet.swift | 64 +++++ .../PrimaryRegionSelectionModelTests.swift | 113 ++++++++ .../Tests/RegionStyleRegistryTests.swift | 52 ++++ .../WhereUI/Tests/WhereStylesheetTests.swift | 23 ++ .../Sources/WhereWidgetProvider.swift | 6 + 33 files changed, 2090 insertions(+), 114 deletions(-) create mode 100644 Where/WhereCore/Sources/Regions/PrimaryRegion.swift create mode 100644 Where/WhereCore/Sources/Regions/RegionAppearance.swift create mode 100644 Where/WhereCore/Tests/PrimaryRegionStoreTests.swift create mode 100644 Where/WhereCore/Tests/RegionAppearanceTests.swift create mode 100644 Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift create mode 100644 Where/WhereUI/Sources/Model/RegionStyleRegistry.swift create mode 100644 Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift create mode 100644 Where/WhereUI/Sources/Regions/RegionCustomizeView.swift create mode 100644 Where/WhereUI/Sources/Regions/RegionPickerView.swift create mode 100644 Where/WhereUI/Sources/Regions/RegionsSettingsView.swift create mode 100644 Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift create mode 100644 Where/WhereUI/Tests/RegionStyleRegistryTests.swift diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 270ccd9e..7a589b81 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -34,6 +34,12 @@ internal shape. production `SwiftDataStore` traps otherwise), and each committed transaction pings `changes()` — the single signal readers refresh from. The live `ModelContainer` is surfaced only for the read-only debug inspector. +- **Primary regions *are* the tracked-region set.** The picked primary regions + (`primaryRegions()` / `setPrimaryRegion(_:id:order:)`) are the same + `SDTrackedRegion` rows `trackedRegions()` reads — picking scopes GPS + attribution *and* carries each region's `RegionAppearance` (color token / + emoji / SF Symbol) + pick order. `RegionAppearance` is data (WhereCore); the + token→`Color` mapping and option catalogs are presentation (`WhereUI`). - **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (year-month- day) is the timezone-independent identity of a day, and it is what every *stored user record* and *day comparison* keys on: `DayPresence.day`, diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 495550d6..bcabef4f 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -26,9 +26,12 @@ one it belongs to rather than to a god-object: atomic transaction) and `changes()` emits once per commit and on a CloudKit remote import. `SwiftDataStore.make()` is the production, CloudKit-backed implementation; `SwiftDataStore.inMemory()` backs tests and previews. It also - holds the user's **tracked regions** (`trackedRegions()` / - `setTrackedRegion(_:id:)`) — one synced row per region, defaulting to the four - until the user chooses. + holds the user's **tracked / primary regions** (`trackedRegions()` / + `setTrackedRegion(_:id:)`, plus `primaryRegions()` / `setPrimaryRegion(_:id:order:)` + which surface and persist each region's picked `RegionAppearance` — color + token, emoji, SF Symbol — and pick order alongside the synced row) — one row + per region, defaulting to the four until the user chooses in the onboarding / + Settings region picker. - **`RegionAttribution`** — a live `RegionAttributing` built from the tracked regions that rebuilds on `changes()` (a local edit or a remote import), so the app + App Intents process attribute against the same synced set. Assemble diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index ee01d0d4..15a5a970 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -686,18 +686,88 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // TODO: Untracking deletes the row, which drops the region from the // attributor's load set — so re-aggregating a past year would // re-attribute that region's GPS days to `.other` (manual days, - // stored as region sets, are unaffected). When the onboarding / - // region picker lands, switch to a soft-delete (mark the row - // inactive, never delete) and have the attributor load every - // ever-tracked region so past reports stay stable, while "active" - // drives the UI/primary set. Not user-reachable yet — nothing calls - // `setTrackedRegion(false)` outside tests. + // stored as region sets, are unaffected). Now user-reachable (the + // onboarding picker and the Settings region editor remove picks via + // `removePrimaryRegion` → here), so switching to a soft-delete (mark + // the row inactive, never delete) and having the attributor load + // every ever-tracked region — so past reports stay stable while + // "active" drives the UI/primary set — is worth doing. for record in existing { context.delete(record) } } } + public func primaryRegions() async throws -> [PrimaryRegion] { + let context = readContext() + var descriptor = FetchDescriptor() + descriptor.includePendingChanges = true + let rows = try context.fetch(descriptor) + // No rows means the user hasn't chosen yet — mirror `trackedRegions()`'s + // default fallback so the picker/customization UI opens on the + // out-of-the-box set rather than empty. + guard !rows.isEmpty else { + return Region.inCanonicalOrder(Self.defaultTrackedRegions) + .enumerated() + .map { PrimaryRegion(region: $1, appearance: nil, order: $0) } + } + var resolved: [PrimaryRegion] = [] + var unknown: [String] = [] + // De-dupe by id (CloudKit can't enforce uniqueness): first row per id + // wins, preferring one that carries an appearance/order. + var seen: Set = [] + let sorted = rows.sorted { lhs, rhs in + let l = lhs.orderIndex ?? Int.max + let r = rhs.orderIndex ?? Int.max + if l != r { return l < r } + return (lhs.regionID ?? "") < (rhs.regionID ?? "") + } + for row in sorted { + guard let id = row.regionID else { continue } + guard let region = Region(rawValue: id) else { + unknown.append(id) + continue + } + guard seen.insert(id).inserted else { continue } + resolved.append(PrimaryRegion( + region: region, + appearance: row.appearanceValue, + order: row.orderIndex ?? resolved.count, + )) + } + if !unknown.isEmpty { + Self.logger.warning( + "Ignored \(unknown.count) unknown primary-region id(s): \(unknown.sorted().joined(separator: ", "))", + ) + } + return resolved + } + + public func setPrimaryRegion( + _ appearance: RegionAppearance?, + id: String, + order: Int?, + ) async throws { + let context = mutationContext() + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.regionID == id }, + ) + let existing = try context.fetch(descriptor) + // Collapse any accidental duplicate rows to one (CloudKit can't enforce + // uniqueness), then upsert the row's membership + appearance + order. + let row: SDTrackedRegion + if let first = existing.first { + for extra in existing.dropFirst() { + context.delete(extra) + } + row = first + } else { + row = SDTrackedRegion(regionID: id) + context.insert(row) + } + row.apply(appearance: appearance, order: order) + } + private static func logFault(forCorrupt _: Record) { logger.fault( "Dropped corrupt SwiftData record of type \(String(describing: Record.self))", @@ -948,13 +1018,40 @@ final class SDDismissedIssue { /// and add/add of the same region collapses to one on read (`trackedRegions()` /// returns a `Set`). `regionID` is `Region.rawValue`; optional per the CloudKit /// mirror's requirement. +/// +/// The `colorRaw` / `emoji` / `symbolName` / `orderIndex` fields carry the +/// region's user-picked ``RegionAppearance`` and pick order. All optional — +/// both because CloudKit requires it and because a row can be tracked without a +/// chosen look yet (the default set, or a legacy row); a resolved appearance +/// needs all three style fields present. @Model final class SDTrackedRegion { var regionID: String? + var colorRaw: String? + var emoji: String? + var symbolName: String? + var orderIndex: Int? init() {} init(regionID: String) { self.regionID = regionID } + + /// The stored appearance, or `nil` when the row has no complete look yet. + var appearanceValue: RegionAppearance? { + guard let colorRaw, let color = RegionColorToken(rawValue: colorRaw), + let emoji, let symbolName + else { return nil } + return RegionAppearance(color: color, emoji: emoji, symbolName: symbolName) + } + + /// Overwrite the stored appearance (clearing the style fields when `nil`) + /// and pick order. + func apply(appearance: RegionAppearance?, order: Int?) { + colorRaw = appearance?.color.rawValue + emoji = appearance?.emoji + symbolName = appearance?.symbolName + orderIndex = order + } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 1268e92d..209af1a1 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -105,10 +105,25 @@ public protocol WhereStore: Sendable { /// ``WhereStore/defaultTrackedRegions``. func trackedRegions() async throws -> Set + /// The user's tracked regions with their picked appearance and pick order — + /// the same rows ``trackedRegions()`` reads, surfaced as ordered + /// ``PrimaryRegion`` values for the picker/customization UI. Ordered by the + /// stored pick order (rows without one sort last, then by region id). When + /// the user hasn't chosen any yet, mirrors ``WhereStore/defaultTrackedRegions`` + /// (in canonical order, with no stored appearance). + func primaryRegions() async throws -> [PrimaryRegion] + /// Add (`tracked == true`) or remove (`false`) a single tracked region by /// its `Region.rawValue`. Per-region so two devices adding different regions /// both survive a sync. Must run inside `perform { ... }`. func setTrackedRegion(_ tracked: Bool, id: String) async throws + + /// Upsert a primary (tracked) region: ensure a row exists for `id`, then + /// store its `appearance` (clearing the stored look when `nil`) and pick + /// `order`. Adding is the primary-picker's write path — it both tracks the + /// region and records its look — while removal still goes through + /// ``setTrackedRegion(_:id:)`` with `false`. Must run inside `perform { ... }`. + func setPrimaryRegion(_ appearance: RegionAppearance?, id: String, order: Int?) async throws } extension WhereStore { @@ -125,7 +140,25 @@ extension WhereStore { Self.defaultTrackedRegions } + /// Default: derive ordered primary regions from ``trackedRegions()`` with no + /// stored appearance. `SwiftDataStore` overrides this to read the persisted + /// appearance and pick order; test fakes inherit this catalog-ordered view. + public func primaryRegions() async throws -> [PrimaryRegion] { + try await Region.inCanonicalOrder(trackedRegions()) + .enumerated() + .map { PrimaryRegion(region: $1, appearance: nil, order: $0) } + } + /// Default: a no-op. `SwiftDataStore` overrides this to persist rows; test /// fakes that don't exercise tracked-region persistence inherit the no-op. public func setTrackedRegion(_: Bool, id _: String) async throws {} + + /// Default: a no-op. `SwiftDataStore` overrides this to persist the row plus + /// its appearance/order; test fakes that don't exercise persistence inherit + /// the no-op. + public func setPrimaryRegion( + _: RegionAppearance?, + id _: String, + order _: Int?, + ) async throws {} } diff --git a/Where/WhereCore/Sources/Regions/PrimaryRegion.swift b/Where/WhereCore/Sources/Regions/PrimaryRegion.swift new file mode 100644 index 00000000..baa1e2de --- /dev/null +++ b/Where/WhereCore/Sources/Regions/PrimaryRegion.swift @@ -0,0 +1,23 @@ +import Foundation +import RegionKit + +/// A region the user has chosen as one of their primary places, paired with its +/// customized ``RegionAppearance`` and the position (``order``) it was picked +/// in. The primary set *is* the tracked-region set — the regions the app loads +/// geometry for and attributes GPS against — so picking these both scopes +/// attribution and drives per-region styling. +/// +/// `appearance` is optional: a region can be tracked without a stored look yet +/// (the out-of-the-box default set, or a legacy row), in which case the +/// presentation layer falls back to a deterministic default style. +public struct PrimaryRegion: Hashable, Sendable { + public let region: Region + public let appearance: RegionAppearance? + public let order: Int + + public init(region: Region, appearance: RegionAppearance?, order: Int) { + self.region = region + self.appearance = appearance + self.order = order + } +} diff --git a/Where/WhereCore/Sources/Regions/RegionAppearance.swift b/Where/WhereCore/Sources/Regions/RegionAppearance.swift new file mode 100644 index 00000000..9e1913c9 --- /dev/null +++ b/Where/WhereCore/Sources/Regions/RegionAppearance.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A stable, storable identifier for a region's accent color. Persisted (as its +/// `rawValue`) rather than a platform `Color` so the choice survives across +/// processes, backups, and the CloudKit mirror; the presentation layer +/// (`WhereUI.RegionStyle`) maps each token to a concrete SwiftUI color. +/// +/// The cases mirror the historical `RegionStyle` default palette so an +/// unpicked region and a picked-then-matched one render identically. +public enum RegionColorToken: String, CaseIterable, Sendable, Codable, Hashable { + case orange + case indigo + case red + case blue + case teal + case green + case mint + case cyan + case purple + case pink + case brown +} + +/// The user-chosen look for a region: an accent color token, an emoji, and an +/// SF Symbol name. Pure data — the concrete color and any option catalogs live +/// in the presentation layer. Persisted alongside the region's tracked-region +/// row so a customized region keeps its look across devices and reinstalls. +public struct RegionAppearance: Hashable, Sendable, Codable { + public var color: RegionColorToken + public var emoji: String + public var symbolName: String + + public init(color: RegionColorToken, emoji: String, symbolName: String) { + self.color = color + self.emoji = emoji + self.symbolName = symbolName + } +} diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 16d4b520..553f7655 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -257,6 +257,33 @@ public struct WhereServices: Sendable { try await store.trackedRegions() } + /// The user's primary (tracked) regions with their picked appearance and + /// order — the store's ``PrimaryRegion`` rows. Drives the region picker / + /// customization UI and seeds the presentation layer's region styling. + public func primaryRegions() async throws -> [PrimaryRegion] { + try await store.primaryRegions() + } + + /// Upsert a primary (tracked) region's appearance + pick order. The + /// picker/customization commit path; runs the write inside `perform`. + public func setPrimaryRegion( + _ appearance: RegionAppearance?, + id: String, + order: Int?, + ) async throws { + try await store.perform { + try await store.setPrimaryRegion(appearance, id: id, order: order) + } + } + + /// Remove a primary (tracked) region entirely. Runs the write inside + /// `perform`. + public func removePrimaryRegion(id: String) async throws { + try await store.perform { + try await store.setTrackedRegion(false, id: id) + } + } + /// Return the services to a clean slate for the app's "erase all data & /// reset" teardown: quiesce GPS ingestion (stop monitoring, refuse further /// samples, await any in-flight write, and drop the retry backlog) so diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index d89262c2..d7f40740 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -19,12 +19,42 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable { /// Day counts per region for `year` (a `YearReport.totals`). A day in /// two regions counts once for each. public let totals: [Region: Int] + /// The user's picked appearances for their primary regions, carried across + /// the App Group so the widget process can render each region's chosen + /// color/emoji/icon (it has no store access and no `WhereSession` to seed + /// `RegionStyleRegistry`). Empty for regions the user hasn't customized (and + /// for snapshots written before this field existed) — those fall back to the + /// default look. + public let appearances: [Region: RegionAppearance] - public init(day: Date, year: Int, dayRegions: Set, totals: [Region: Int]) { + public init( + day: Date, + year: Int, + dayRegions: Set, + totals: [Region: Int], + appearances: [Region: RegionAppearance] = [:], + ) { self.day = day self.year = year self.dayRegions = dayRegions self.totals = totals + self.appearances = appearances + } + + private enum CodingKeys: String, CodingKey { + case day, year, dayRegions, totals, appearances + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + day = try container.decode(Date.self, forKey: .day) + year = try container.decode(Int.self, forKey: .year) + dayRegions = try container.decode(Set.self, forKey: .dayRegions) + totals = try container.decode([Region: Int].self, forKey: .totals) + // Additive: snapshots written before `appearances` existed decode to an + // empty map rather than failing (the widget then uses default looks). + appearances = try container + .decodeIfPresent([Region: RegionAppearance].self, forKey: .appearances) ?? [:] } } @@ -70,11 +100,16 @@ public struct WidgetDataReader: Sendable { let dayRegions = report.days .first { $0.day == calendarDay }? .regions ?? [] + var appearances: [Region: RegionAppearance] = [:] + for primary in try await store.primaryRegions() { + if let appearance = primary.appearance { appearances[primary.region] = appearance } + } return WidgetSnapshot( day: startOfDay, year: year, dayRegions: dayRegions, totals: report.totals, + appearances: appearances, ) } } diff --git a/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift new file mode 100644 index 00000000..588c73d3 --- /dev/null +++ b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift @@ -0,0 +1,89 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +/// The store's primary-region view: the same tracked-region rows, surfaced with +/// their picked ``RegionAppearance`` and pick order for the picker/customization +/// UI. +struct PrimaryRegionStoreTests { + private func appearance(_ color: RegionColorToken, _ emoji: String, _ symbol: String) + -> RegionAppearance + { + RegionAppearance(color: color, emoji: emoji, symbolName: symbol) + } + + @Test func defaultsMirrorTheTrackedFallbackWhenNoRows() async throws { + let store = try SwiftDataStore.inMemory() + let primary = try await store.primaryRegions() + #expect(Set(primary.map(\.region)) == SwiftDataStore.defaultTrackedRegions) + #expect(primary.allSatisfy { $0.appearance == nil }) + // Order is dense and canonical (0.. RegionAppearance { + bespokeAppearances[region.rawValue] + ?? RegionAppearance( + color: defaultColor(for: region.rawValue), + emoji: "📍", + symbolName: "mappin.circle.fill", + ) + } + + private static let bespokeAppearances: [String: RegionAppearance] = [ + Region.california.rawValue: + RegionAppearance(color: .orange, emoji: "🌴", symbolName: "sun.max.fill"), + Region.newYork.rawValue: + RegionAppearance(color: .indigo, emoji: "🗽", symbolName: "building.2.fill"), + Region.canada.rawValue: + RegionAppearance(color: .red, emoji: "🍁", symbolName: "leaf.fill"), + Region.europeanUnion.rawValue: + RegionAppearance(color: .blue, emoji: "🇪🇺", symbolName: "star.fill"), + Region.other.rawValue: + RegionAppearance(color: .teal, emoji: "🧭", symbolName: "mappin.circle.fill"), + ] + + /// A stable accent token derived from the region id, matching the order of + /// ``colors`` so an auto-assigned color is one the picker can also show. + private static func defaultColor(for id: String) -> RegionColorToken { + let sum = id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } + return colors[sum % colors.count] + } +} + +extension RegionColorToken { + /// The concrete SwiftUI color for this token. Shared by `RegionStyle` and the + /// color picker so a swatch and the rendered card always match. + public var color: Color { + switch self { + case .orange: .orange + case .indigo: .indigo + case .red: .red + case .blue: .blue + case .teal: .teal + case .green: .green + case .mint: .mint + case .cyan: .cyan + case .purple: .purple + case .pink: .pink + case .brown: .brown + } + } +} diff --git a/Where/WhereUI/Sources/Model/RegionStyle.swift b/Where/WhereUI/Sources/Model/RegionStyle.swift index 40c6f1ad..781eb0cc 100644 --- a/Where/WhereUI/Sources/Model/RegionStyle.swift +++ b/Where/WhereUI/Sources/Model/RegionStyle.swift @@ -21,69 +21,33 @@ public struct RegionStyle: Sendable { self.tint = tint } - public static func style(for region: Region) -> RegionStyle { - bespokeStyles[region.rawValue] ?? defaultStyle(for: region) - } - - /// Hand-tuned looks for the regions the app shipped with (and the `.other` - /// catch-all). - /// - /// TODO: Remove when user-chosen per-region styling lands — these bespoke - /// looks become user data (a picked emoji / symbol / tint). Deleting this - /// one table cleanly falls back to `defaultStyle(for:)`; nothing else here - /// hard-codes a region. - private static let bespokeStyles: [String: RegionStyle] = [ - Region.california.rawValue: RegionStyle( - symbolName: "sun.max.fill", - emoji: "🌴", - tint: .orange, - ), - Region.newYork.rawValue: RegionStyle( - symbolName: "building.2.fill", - emoji: "🗽", - tint: .indigo, - ), - Region.canada.rawValue: RegionStyle(symbolName: "leaf.fill", emoji: "🍁", tint: .red), - Region.europeanUnion.rawValue: RegionStyle( - symbolName: "star.circle.fill", - emoji: "🇪🇺", - tint: .blue, - ), - Region.other.rawValue: RegionStyle( - symbolName: "location.magnifyingglass", - emoji: "🧭", - tint: .teal, - ), - ] - - /// A stable default look for any region without a bespoke entry: a generic - /// map-pin symbol/emoji and a tint chosen deterministically from the id, so - /// the same region is always the same color across launches. - private static func defaultStyle(for region: Region) -> RegionStyle { - RegionStyle( - symbolName: "mappin.circle.fill", - emoji: "📍", - tint: defaultPalette[paletteIndex(for: region.rawValue)], + /// Build a style from a user-picked ``RegionAppearance`` (token → color). + public init(_ appearance: RegionAppearance) { + self.init( + symbolName: appearance.symbolName, + emoji: appearance.emoji, + tint: appearance.color.color, ) } - private static let defaultPalette: [Color] = [ - .orange, - .indigo, - .red, - .blue, - .teal, - .green, - .mint, - .cyan, - .purple, - .pink, - .brown, - ] + /// The region's look: the user's picked appearance if they've customized it + /// (via `RegionStyleRegistry`, seeded from the store), otherwise a stable + /// fallback — a small table of hand-tuned looks for the regions the app + /// shipped with, and an id-derived default for everything else. + public static func style(for region: Region) -> RegionStyle { + if let appearance = RegionStyleRegistry.shared.appearance(for: region) { + return RegionStyle(appearance) + } + return fallbackStyle(for: region) + } - private static func paletteIndex(for id: String) -> Int { - let sum = id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } - return sum % defaultPalette.count + /// The look for a region with no user-picked appearance: the region's + /// default ``RegionAppearance`` (a hand-tuned look for the regions the app + /// shipped with, an id-derived default for everything else). Sharing + /// `RegionAppearanceCatalog.defaultAppearance(for:)` keeps the fallback and + /// the customization pre-fill in lockstep — a picked appearance always wins. + static func fallbackStyle(for region: Region) -> RegionStyle { + RegionStyle(RegionAppearanceCatalog.defaultAppearance(for: region)) } } diff --git a/Where/WhereUI/Sources/Model/RegionStyleRegistry.swift b/Where/WhereUI/Sources/Model/RegionStyleRegistry.swift new file mode 100644 index 00000000..9b515d49 --- /dev/null +++ b/Where/WhereUI/Sources/Model/RegionStyleRegistry.swift @@ -0,0 +1,45 @@ +import RegionKit +import Synchronization +import WhereCore + +/// Process-wide source of the user's picked region appearances that backs +/// `RegionStyle.style(for:)`. It exists so the ergonomic, synchronous +/// `region.style` accessor — read from view bodies, widget views, and App +/// Intents snippets alike — can resolve *persisted* looks without every call +/// site threading a lookup or an environment value. +/// +/// Written from a single place per process: the app seeds it from the store via +/// `WhereSession` (at launch and on every store change); the widget process +/// seeds it from the `WidgetSnapshot` it renders. Reads are lock-guarded so the +/// off-main-actor callers (snippets) are safe. A region with no entry falls back +/// to the deterministic default look in `RegionStyle`. +public final class RegionStyleRegistry: Sendable { + public static let shared = RegionStyleRegistry() + + private let storage = Mutex<[Region: RegionAppearance]>([:]) + + public init() {} + + /// The picked appearance for `region`, or `nil` when the user hasn't + /// customized it (so `RegionStyle` uses its fallback default). + public func appearance(for region: Region) -> RegionAppearance? { + storage.withLock { $0[region] } + } + + /// Replace the whole map — the primary-region set is small and always read + /// as a whole, so a wholesale swap keeps the registry a faithful mirror of + /// the store (a removed region loses its entry, not just changed ones). + public func replaceAll(_ appearances: [Region: RegionAppearance]) { + storage.withLock { $0 = appearances } + } + + /// Build the map from ordered `PrimaryRegion`s, keeping only the ones that + /// carry a resolved appearance. + public func replaceAll(from primaryRegions: [PrimaryRegion]) { + var map: [Region: RegionAppearance] = [:] + for entry in primaryRegions { + if let appearance = entry.appearance { map[entry.region] = appearance } + } + replaceAll(map) + } +} diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 11619564..701acbfc 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -77,6 +77,11 @@ public final class WhereSession { /// access to race. @ObservationIgnored private nonisolated(unsafe) var authorizationTask: Task? + /// Observes `dataChangeUpdates()` to keep `RegionStyleRegistry` in sync with + /// the store's picked region appearances. Same `nonisolated(unsafe)` rationale + /// as `authorizationTask` — only touched on the main actor except `deinit`. + @ObservationIgnored private nonisolated(unsafe) var regionStyleTask: Task? + private static let logger = WhereLog.channel(.session) /// The authorization the degradation warning was last evaluated against. @@ -136,6 +141,7 @@ public final class WhereSession { /// until the next status change resumes it. deinit { authorizationTask?.cancel() + regionStyleTask?.cancel() } /// Sync authorization, resume tracking if appropriate, apply the reminder / @@ -149,6 +155,8 @@ public final class WhereSession { public func start() async { await syncAuthorization() observeAuthorizationChanges() + await seedRegionStyles() + observeRegionStyleChanges() await reconcileTracking() await captureTodayIfNeeded() await applyReminderConfiguration() @@ -233,6 +241,34 @@ public final class WhereSession { } } + /// Load the user's picked region appearances into `RegionStyleRegistry` so + /// `region.style` reflects them everywhere the UI renders a region. A launch + /// step (see `WhereLaunch.sequence`); also re-run on every store change via + /// `observeRegionStyleChanges()`. On failure the registry keeps its last good + /// map (honest degraded state) and the failure is logged. + func seedRegionStyles() async { + do { + let primary = try await services.primaryRegions() + RegionStyleRegistry.shared.replaceAll(from: primary) + } catch { + Self.logger.warning("Failed to load region appearances for styling") + } + } + + /// Subscribe to store changes (local commits + remote CloudKit imports) so a + /// customized region's look stays live — a Settings edit or a synced pick on + /// another device reseeds `RegionStyleRegistry`. Idempotent. + func observeRegionStyleChanges() { + guard regionStyleTask == nil else { return } + let services = services + regionStyleTask = Task { @MainActor [weak self] in + for await _ in services.dataChangeUpdates() { + guard let self else { break } + await seedRegionStyles() + } + } + } + /// Start or stop GPS ingestion so it matches the user's intent and the /// current authorization. Tracking only runs with Always authorization. A /// launch step (see `WhereLaunch.sequence`). diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 8e80c918..ca13444f 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -2,27 +2,44 @@ import LifecycleKit import SwiftUI import WhereCore -/// First-run onboarding: a short paged intro to the passport concept that -/// culminates in the background-location permission request — the natural -/// place to ask for Always, rather than burying it in Settings. +/// First-run onboarding, run as one interactive launch step. A short paged +/// intro to the passport concept, then picking the primary regions you spend +/// time in and giving each a look, then the background-location permission +/// request — the natural place to ask for Always, rather than burying it in +/// Settings. /// -/// Presented as the UI of the launch sequence's interactive `onboarding` step. -/// When the user finishes, it persists `hasOnboarded` and resolves the -/// `LifecycleStepUIBridge` so the launch continues; the existing -/// authorization-sync and tracking-reconcile steps then pick up whatever +/// When the user finishes it commits the picked regions + appearances to the +/// store (which becomes the tracked-region set), persists `hasOnboarded`, and +/// resolves the `LifecycleStepUIBridge` so the launch continues; the following +/// authorization-sync step then seeds region styling and picks up whatever /// permission was granted. public struct OnboardingView: View { // Onboarding straddles both: it persists the app-level `hasOnboarded` flag - // (model) and kicks off background tracking through the session, which the - // `open-store` step has already built by the time this step runs. + // (model) and kicks off background tracking + the region commit through the + // session, which the `open-store` step has already built by the time this + // step runs. @Environment(WhereModel.self) private var model @Environment(WhereSession.self) private var session @Environment(\.stylesheet) private var stylesheet private let bridge: LifecycleStepUIBridge + /// The ordered onboarding phases. An explicit state machine (rather than + /// loose flags) so only one screen is ever showing and the transitions are + /// obvious. + private enum Phase: Hashable { + case intro + case pickRegions + case customize + case location + } + + @State private var phase: Phase = .intro @State private var page = 0 + @State private var selection = PrimaryRegionSelectionModel() @State private var isFinishing = false + private static let logger = WhereLog.channel(.model) + public init(bridge: LifecycleStepUIBridge) { self.bridge = bridge } @@ -30,19 +47,13 @@ public struct OnboardingView: View { private let pages = OnboardingPage.all public var body: some View { - VStack(spacing: stylesheet.spacing.xxxLarge) { - TabView(selection: $page) { - ForEach(pages.indices, id: \.self) { index in - pageView(pages[index]) - .tag(index) - .padding(.horizontal, stylesheet.spacing.xxxLarge) - } + Group { + switch phase { + case .intro: intro + case .pickRegions: pickRegions + case .customize: customize + case .location: location } - .tabViewStyle(.page(indexDisplayMode: .always)) - - footer - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.bottom, stylesheet.spacing.xxxLarge) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( @@ -56,6 +67,26 @@ public struct OnboardingView: View { ) .ignoresSafeArea(), ) + .animation(stylesheet.motion.reducedReveal, value: phase) + } + + // MARK: - Intro + + private var intro: some View { + VStack(spacing: stylesheet.spacing.xxxLarge) { + TabView(selection: $page) { + ForEach(pages.indices, id: \.self) { index in + pageView(pages[index]) + .tag(index) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + + introFooter + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) + } } private func pageView(_ page: OnboardingPage) -> some View { @@ -78,28 +109,89 @@ public struct OnboardingView: View { } } - @ViewBuilder private var footer: some View { - if page < pages.count - 1 { - Button { + private var introFooter: some View { + Button { + if page < pages.count - 1 { withAnimation { page += 1 } + } else { + phase = .pickRegions + } + } label: { + Text(Strings.onboardingContinue) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + + // MARK: - Pick regions + + private var pickRegions: some View { + VStack(spacing: stylesheet.spacing.large) { + VStack(spacing: stylesheet.spacing.small) { + Text(Strings.onboardingRegionsTitle) + .font(.title.bold()) + .multilineTextAlignment(.center) + Text(Strings.regionPickerSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.top, stylesheet.spacing.xxxLarge) + + RegionPickerView(model: selection) + + Button { + phase = .customize } label: { - Text(Strings.onboardingContinue) + Text(Strings.onboardingNext) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - } else { + .disabled(!selection.hasSelection) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) + } + } + + // MARK: - Customize + + private var customize: some View { + RegionCustomizeView( + model: selection, + onBack: { phase = .pickRegions }, + onFinish: { phase = .location }, + ) + } + + // MARK: - Location + + private var location: some View { + VStack(spacing: stylesheet.spacing.xxxLarge) { + Spacer(minLength: 0) + Image(systemName: "location.fill.viewfinder") + .font(stylesheet.typography.onboardingIcon) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(spacing: stylesheet.spacing.large) { + Text(Strings.onboardingLocationTitle) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text(Strings.onboardingLocationDescription) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Spacer(minLength: 0) + VStack(spacing: stylesheet.spacing.large) { Button { // Request Always-location right here so the system prompt // maps 1:1 to the tap; the launch's tracking-reconcile step // picks up whatever was granted. - guard !isFinishing else { return } - isFinishing = true - Task { - await session.startTracking() - completeAndContinue() - } + finish(enableLocation: true) } label: { Text(Strings.onboardingEnableLocation) .frame(maxWidth: .infinity) @@ -108,22 +200,35 @@ public struct OnboardingView: View { .controlSize(.large) Button(Strings.onboardingNotNow) { - guard !isFinishing else { return } - isFinishing = true - completeAndContinue() + finish(enableLocation: false) } .controlSize(.large) } .disabled(isFinishing) } + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) } - /// Persist that onboarding is done and resolve the step's bridge so the - /// launch continues. Shared by the "Enable Location" and "Not now" taps; - /// the permission request, when wanted, is made by the button before this. - private func completeAndContinue() { - model.completeOnboarding() - bridge.complete() + /// Commit the picked regions + appearances, optionally request location, + /// then persist `hasOnboarded` and resolve the step so the launch continues. + private func finish(enableLocation: Bool) { + guard !isFinishing else { return } + isFinishing = true + Task { + if enableLocation { + await session.startTracking() + } + do { + try await selection.commit(using: session) + } catch { + // Don't strand the user in onboarding on a write failure — log + // it and continue; they can re-pick in Settings. + Self.logger.warning("Failed to commit onboarding region picks") + } + model.completeOnboarding() + bridge.complete() + } } } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index c5113e1b..8e2b001f 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -70,6 +70,38 @@ WhereSession(services: previewServices()) } + // MARK: - Region picker / customization + + /// A primary-region selection model seeded with a few US picks + looks, + /// for the picker/customization previews and tests. + @MainActor + public static func primaryRegionSelectionModel() -> PrimaryRegionSelectionModel { + let texas = Region(rawValue: "us-TX") + let existing: [PrimaryRegion] = [ + PrimaryRegion( + region: .california, + appearance: RegionAppearance( + color: .orange, + emoji: "🌴", + symbolName: "sun.max.fill", + ), + order: 0, + ), + PrimaryRegion( + region: .newYork, + appearance: RegionAppearance( + color: .indigo, + emoji: "🗽", + symbolName: "building.2.fill", + ), + order: 1, + ), + ] + (texas.map { + [PrimaryRegion(region: $0, appearance: nil, order: 2)] + } ?? []) + return PrimaryRegionSelectionModel(existing: existing) + } + // MARK: - Report models (scene / report + year views) /// A ready-to-render scene report model with the sample report injected diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index d495c532..2bbb1429 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -37,6 +37,12 @@ struct RegionSummaryCard: View { /// static sheen. var tilt: TiltProvider? + /// An explicit style to render instead of the region's persisted + /// `region.style`. The region-customization screen passes the in-progress + /// draft appearance so the card previews a pick before it's saved; every + /// other caller leaves it `nil` and gets the stored look. + var styleOverride: RegionStyle? + @Environment(\.stylesheet) private var stylesheet /// The resolved spec for this card's variant, read once so the rest of the @@ -46,7 +52,7 @@ struct RegionSummaryCard: View { } private var style: RegionStyle { - regionDays.region.style + styleOverride ?? regionDays.region.style } private var cardShape: RoundedRectangle { diff --git a/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift new file mode 100644 index 00000000..e6948359 --- /dev/null +++ b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift @@ -0,0 +1,137 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// The view-scoped model behind the primary-region picker + customization flow, +/// shared by onboarding and Settings. Holds the ordered selection (capped at +/// ``maxSelection``), each region's in-progress ``RegionAppearance`` draft, and +/// the commit that syncs the choice to the store through a `WhereServices` +/// collaborator. +/// +/// The order of ``selectedRegions`` is the pick order — it drives the +/// customization step sequence and the stored `PrimaryRegion.order`. +@MainActor +@Observable +public final class PrimaryRegionSelectionModel { + /// The most primary regions a user can pick. + public static let maxSelection = 5 + + /// The regions offered, in catalog order (US jurisdictions only for now). + public let available: [Region] + + /// The picked regions, in pick order. + public private(set) var selectedRegions: [Region] = [] + + /// Per-region appearance drafts. A region without an entry uses its + /// ``RegionAppearanceCatalog/defaultAppearance(for:)`` until customized. + private var drafts: [Region: RegionAppearance] = [:] + + /// The regions tracked when the model was created, so the commit can + /// untrack the ones the user removed. + private let initialRegions: Set + + /// The US jurisdictions from the catalog, in canonical order — the default + /// `available` set for the picker. + public static var usRegions: [Region] { + RegionCatalog.shared.all.filter { $0.rawValue.hasPrefix("us-") } + } + + /// A fresh picker with nothing selected (onboarding). + public init(available: [Region] = PrimaryRegionSelectionModel.usRegions) { + self.available = available + initialRegions = [] + } + + /// A picker seeded from the user's existing primary regions (Settings). + /// Selection and drafts are restored so re-opening the editor shows the + /// current picks; regions outside `available` (e.g. legacy non-US defaults) + /// are still counted in `initialRegions` so the commit untracks them. + public init( + existing: [PrimaryRegion], + available: [Region] = PrimaryRegionSelectionModel.usRegions, + ) { + self.available = available + initialRegions = Set(existing.map(\.region)) + let offered = Set(available) + selectedRegions = existing.map(\.region).filter { offered.contains($0) } + var drafts: [Region: RegionAppearance] = [:] + for entry in existing { + if let appearance = entry.appearance { drafts[entry.region] = appearance } + } + self.drafts = drafts + } + + public var selectionCount: Int { + selectedRegions.count + } + + public var isAtCapacity: Bool { + selectedRegions.count >= Self.maxSelection + } + + public var hasSelection: Bool { + !selectedRegions.isEmpty + } + + public func isSelected(_ region: Region) -> Bool { + selectedRegions.contains(region) + } + + /// Whether tapping `region` would do something: always true when it's + /// already selected (a tap removes it), otherwise only when there's room. + public func canToggle(_ region: Region) -> Bool { + isSelected(region) || !isAtCapacity + } + + /// Add `region` (appending to preserve pick order) or remove it. A no-op + /// when adding past ``maxSelection``. + public func toggle(_ region: Region) { + if let index = selectedRegions.firstIndex(of: region) { + selectedRegions.remove(at: index) + } else if !isAtCapacity { + selectedRegions.append(region) + } + } + + /// The current appearance draft for `region` (its default until edited). + public func appearance(for region: Region) -> RegionAppearance { + drafts[region] ?? RegionAppearanceCatalog.defaultAppearance(for: region) + } + + public func setColor(_ color: RegionColorToken, for region: Region) { + var appearance = appearance(for: region) + appearance.color = color + drafts[region] = appearance + } + + public func setEmoji(_ emoji: String, for region: Region) { + var appearance = appearance(for: region) + appearance.emoji = emoji + drafts[region] = appearance + } + + public func setSymbol(_ symbolName: String, for region: Region) { + var appearance = appearance(for: region) + appearance.symbolName = symbolName + drafts[region] = appearance + } + + /// Persist the selection: untrack removed regions, then upsert each picked + /// region's appearance and pick order. Runs each write through the services + /// layer (which owns the `perform` transaction). Throws on the first + /// failure so the caller can surface it — no partial success is hidden. + public func commit(using session: WhereSession) async throws { + let selected = Set(selectedRegions) + for region in initialRegions where !selected.contains(region) { + try await session.services.removePrimaryRegion(id: region.rawValue) + } + for (index, region) in selectedRegions.enumerated() { + try await session.services.setPrimaryRegion( + appearance(for: region), + id: region.rawValue, + order: index, + ) + } + } +} diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift new file mode 100644 index 00000000..4920f8ef --- /dev/null +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -0,0 +1,253 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Editor for one region's look: color, emoji, and icon grids picked from the +/// predefined `RegionAppearanceCatalog`, above a live `RegionSummaryCard` +/// preview that reflects the in-progress draft. Writes straight into the shared +/// ``PrimaryRegionSelectionModel`` draft; reused by the onboarding stepping flow +/// and the Settings editor. +struct RegionAppearanceEditor: View { + @Bindable var model: PrimaryRegionSelectionModel + let region: Region + + @Environment(\.stylesheet) private var stylesheet + + private var appearance: RegionAppearance { + model.appearance(for: region) + } + + var body: some View { + ScrollView { + VStack(spacing: stylesheet.spacing.xLarge) { + preview + + section(Strings.regionCustomizeColor) { + colorGrid + } + section(Strings.regionCustomizeEmoji) { + emojiGrid + } + section(Strings.regionCustomizeSymbol) { + symbolGrid + } + } + .padding(stylesheet.spacing.large) + } + } + + private var preview: some View { + RegionSummaryCard( + regionDays: RegionDays(region: region, days: 128), + caption: Strings.regionCustomizeSubtitle(region: region.localizedName), + styleOverride: RegionStyle(appearance), + ) + .animation(stylesheet.motion.captionFade, value: appearance) + } + + private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: stylesheet.spacing.medium) { + Text(title) + .font(.headline) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var style: WhereStylesheet.RegionPickerStyle { + stylesheet.regionPicker + } + + private var colorColumns: [GridItem] { + [GridItem( + .adaptive(minimum: style.colorSwatchMinWidth), + spacing: stylesheet.spacing.medium, + )] + } + + private var colorGrid: some View { + LazyVGrid(columns: colorColumns, spacing: stylesheet.spacing.medium) { + ForEach(RegionAppearanceCatalog.colors, id: \.self) { token in + let isSelected = appearance.color == token + Button { + model.setColor(token, for: region) + } label: { + Circle() + .fill(token.color) + .frame(width: style.colorSwatchSize, height: style.colorSwatchSize) + .overlay { + Circle() + .strokeBorder( + .primary, + lineWidth: isSelected ? style.colorSwatchSelectionRing : 0, + ) + } + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.regionColorAccessibility(token)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + } + + private var glyphColumns: [GridItem] { + [GridItem(.adaptive(minimum: style.glyphTileMinWidth), spacing: stylesheet.spacing.small)] + } + + private var emojiGrid: some View { + LazyVGrid(columns: glyphColumns, spacing: stylesheet.spacing.small) { + ForEach(RegionAppearanceCatalog.emojis, id: \.self) { emoji in + let isSelected = appearance.emoji == emoji + Button { + model.setEmoji(emoji, for: region) + } label: { + Text(emoji) + .font(.title2) + .frame(width: style.glyphTileSize, height: style.glyphTileSize) + .background(selectionBackground(isSelected)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + } + + private var symbolGrid: some View { + LazyVGrid(columns: glyphColumns, spacing: stylesheet.spacing.small) { + ForEach(RegionAppearanceCatalog.symbols, id: \.self) { symbol in + let isSelected = appearance.symbolName == symbol + Button { + model.setSymbol(symbol, for: region) + } label: { + Image(systemName: symbol) + .font(.title3) + .foregroundStyle(appearance.color.color) + .frame(width: style.glyphTileSize, height: style.glyphTileSize) + .background(selectionBackground(isSelected)) + } + .buttonStyle(.plain) + .accessibilityLabel(symbol) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + } + + private func selectionBackground(_ isSelected: Bool) -> some View { + RoundedRectangle(cornerRadius: style.glyphCornerRadius, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(style.glyphSelectedBackgroundOpacity) + : Color(.secondarySystemBackground)) + .overlay { + RoundedRectangle(cornerRadius: style.glyphCornerRadius, style: .continuous) + .strokeBorder( + Color.accentColor, + lineWidth: isSelected ? style.glyphSelectionStrokeWidth : 0, + ) + } + } +} + +/// Steps through each picked region in pick order, editing its look with +/// ``RegionAppearanceEditor``, with Back/Next controls. `onFinish` fires when +/// the user advances past the last region; `onBack` fires when they go back +/// before the first (so the onboarding flow can return to the picker). Used by +/// onboarding; Settings edits a single region with the editor directly. +struct RegionCustomizeView: View { + @Bindable var model: PrimaryRegionSelectionModel + var onBack: () -> Void = {} + var onFinish: () -> Void = {} + + @State private var index = 0 + + @Environment(\.stylesheet) private var stylesheet + + private var regions: [Region] { + model.selectedRegions + } + + var body: some View { + VStack(spacing: 0) { + if let region = currentRegion { + Text(Strings.regionCustomizeStep(current: index + 1, total: regions.count)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, stylesheet.spacing.small) + + RegionAppearanceEditor(model: model, region: region) + .id(region) + .transition(.opacity) + + controls + } else { + // No selection to customize — nothing to step through. + ContentUnavailableView( + Strings.regionPickerTitle, + systemImage: "map", + description: Text(Strings.regionPickerSubtitle), + ) + } + } + .animation(stylesheet.motion.captionFade, value: index) + .navigationTitle(Strings.regionCustomizeTitle) + } + + private var currentRegion: Region? { + regions.indices.contains(index) ? regions[index] : nil + } + + private var controls: some View { + HStack(spacing: stylesheet.spacing.large) { + Button(Strings.onboardingBack, action: goBack) + .buttonStyle(.bordered) + + Button(isLast ? Strings.commonDone : Strings.onboardingNext, action: goNext) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + .padding(stylesheet.spacing.large) + } + + private var isLast: Bool { + index >= regions.count - 1 + } + + private func goBack() { + if index == 0 { + onBack() + } else { + index -= 1 + } + } + + private func goNext() { + if isLast { + onFinish() + } else { + index += 1 + } + } +} + +#if DEBUG + #Preview("Editor") { + RegionAppearanceEditor( + model: PreviewSupport.primaryRegionSelectionModel(), + region: .california, + ) + .whereBroadwayRoot() + } + + #Preview("Stepping") { + NavigationStack { + RegionCustomizeView(model: PreviewSupport.primaryRegionSelectionModel()) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift new file mode 100644 index 00000000..b71d9371 --- /dev/null +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -0,0 +1,246 @@ +import MapKit +import RegionKit +import SwiftUI +import WhereCore + +/// Picks the user's primary regions — up to `PrimaryRegionSelectionModel.maxSelection` +/// — either by tapping states on a map or from a searchable list, switched via a +/// top-aligned segmented control. Selection lives in the shared +/// ``PrimaryRegionSelectionModel``; this view only reads/toggles it. Used inside +/// onboarding and the Settings region editor. +struct RegionPickerView: View { + @Bindable var model: PrimaryRegionSelectionModel + + /// Map vs list, the two ways to pick. + enum Mode: String, CaseIterable, Hashable { + case map + case list + } + + @State private var mode: Mode = .map + @State private var searchText = "" + /// The loaded map geometry + a matching attributor for tap hit-testing. One + /// `Result` so "loading" (`nil`), success, and failure can't be confused. + @State private var mapData: Result? + + @Environment(\.stylesheet) private var stylesheet + + private static let logger = WhereLog.channel(.regionAttribution) + + var body: some View { + VStack(spacing: stylesheet.spacing.medium) { + modePicker + + Text(Strings.regionPickerSelectionCount( + selected: model.selectionCount, + max: PrimaryRegionSelectionModel.maxSelection, + )) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.updatesFrequently) + + switch mode { + case .map: + mapContent + case .list: + listContent + } + } + .task { + if mapData == nil { await loadMap() } + } + } + + private var modePicker: some View { + Picker(Strings.regionPickerModePicker, selection: $mode) { + Text(Strings.regionPickerModeMap).tag(Mode.map) + Text(Strings.regionPickerModeList).tag(Mode.list) + } + .pickerStyle(.segmented) + .padding(.horizontal, stylesheet.spacing.large) + } + + // MARK: - Map + + @ViewBuilder + private var mapContent: some View { + switch mapData { + case .none: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .some(.failure(error)): + ContentUnavailableView( + Strings.regionPickerLoadErrorTitle, + systemImage: "map", + description: Text(error.localizedDescription), + ) + case let .some(.success(data)): + map(data) + } + } + + private func map(_ data: MapData) -> some View { + let style = stylesheet.regionPicker + return MapReader { proxy in + Map(initialPosition: .region(unitedStatesRegion)) { + ForEach(data.outlines) { outline in + let selected = outline.region.map(model.isSelected) ?? false + let tint = outline.region?.style.tint ?? .gray + MapPolygon(coordinates: outline.coordinates.clLocationCoordinates) + .foregroundStyle(tint.opacity( + selected ? style.selectedFillOpacity : style.unselectedFillOpacity, + )) + .stroke( + tint.opacity( + selected + ? style.selectedStrokeOpacity + : style.unselectedStrokeOpacity, + ), + lineWidth: selected + ? style.selectedStrokeWidth + : style.unselectedStrokeWidth, + ) + } + } + .mapStyle(.standard(pointsOfInterest: .excludingAll)) + .onTapGesture { point in + guard let coordinate = proxy.convert(point, from: .local) else { return } + handleMapTap(at: coordinate, in: data) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: style.mapCornerRadius, style: .continuous)) + .padding(.horizontal, stylesheet.spacing.large) + .accessibilityLabel(Strings.regionPickerMapAccessibility) + } + + private func handleMapTap(at coordinate: CLLocationCoordinate2D, in data: MapData) { + let region = data.attributor.region(at: Coordinate(coordinate)) + guard region != .other, model.available.contains(region) else { return } + // Ignore an add that would exceed the cap so a tap can't silently fail + // to register while still looking tappable. + guard model.canToggle(region) else { return } + model.toggle(region) + } + + // MARK: - List + + private var filteredRegions: [Region] { + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return model.available } + return model.available.filter { + $0.localizedName.localizedCaseInsensitiveContains(query) + } + } + + private var listContent: some View { + List { + ForEach(filteredRegions, id: \.self) { region in + Button { + model.toggle(region) + } label: { + RegionPickerRow( + region: region, + isSelected: model.isSelected(region), + ) + } + .disabled(!model.canToggle(region)) + } + } + .listStyle(.plain) + .searchable(text: $searchText, prompt: Strings.regionPickerSearchPrompt) + .overlay { + if filteredRegions.isEmpty { + ContentUnavailableView.search(text: searchText) + } + } + } + + // MARK: - Loading + + /// The loaded US geometry plus the attributor built from the same regions, + /// so a map tap resolves to exactly the regions the outlines drew. + struct MapData { + let outlines: [RegionOutline] + let attributor: RegionAttributor + } + + private func loadMap() async { + let regions = model.available + do { + // Building the attributor parses every offered region's GeoJSON, so + // keep it off the main actor; the outline read is then cheap. + let attributor = await Task + .detached(priority: .userInitiated) { RegionAttributor(for: regions) } + .value + let outlines = try await RegionGeometryCatalog.outlines( + for: .attribution, + attributor: attributor, + ) + guard !Task.isCancelled else { return } + mapData = .success(MapData(outlines: outlines, attributor: attributor)) + } catch { + guard !Task.isCancelled else { return } + // Keep the failure observable in both the UI (error state) and the + // logs rather than showing a blank map. + Self.logger.warning("Region picker failed to load map geometry: \(error)") + mapData = .failure(error) + } + } + + /// A camera framed on the contiguous US, a sensible default for a US-only + /// picker (the user can pan to Alaska/Hawaii). Built from the stylesheet's + /// raw degrees so the token stays MapKit-free. + private var unitedStatesRegion: MKCoordinateRegion { + let style = stylesheet.regionPicker + return MKCoordinateRegion( + center: CLLocationCoordinate2D( + latitude: style.mapCenterLatitude, + longitude: style.mapCenterLongitude, + ), + span: MKCoordinateSpan( + latitudeDelta: style.mapSpanLatitude, + longitudeDelta: style.mapSpanLongitude, + ), + ) + } +} + +/// A selectable region row: the region's emoji + name with a trailing checkmark +/// when picked. +private struct RegionPickerRow: View { + let region: Region + let isSelected: Bool + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + HStack(spacing: stylesheet.spacing.medium) { + Text(region.style.emoji) + .accessibilityHidden(true) + Text(region.localizedName) + .foregroundStyle(.primary) + Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark") + .fontWeight(.semibold) + .foregroundStyle(region.style.tint) + } + } + .contentShape(.rect) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } +} + +#if DEBUG + #Preview("Empty") { + RegionPickerView(model: PrimaryRegionSelectionModel()) + .whereBroadwayRoot() + } + + #Preview("Seeded") { + RegionPickerView(model: PreviewSupport.primaryRegionSelectionModel()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift new file mode 100644 index 00000000..223c4bc7 --- /dev/null +++ b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift @@ -0,0 +1,112 @@ +import SwiftUI +import WhereCore + +/// Settings screen for editing your primary regions after onboarding: reuses +/// the same picker and per-region customization the first run uses. Loads the +/// current picks, lets you add/remove (up to the cap) and re-style each, and +/// commits on Save. +struct RegionsSettingsView: View { + @Environment(WhereSession.self) private var session + @Environment(\.dismiss) private var dismiss + @Environment(\.stylesheet) private var stylesheet + + /// The picker/customization model, built once the current picks load. + @State private var model: PrimaryRegionSelectionModel? + @State private var phase: Phase = .pick + @State private var isSaving = false + + private enum Phase: Hashable { + case pick + case customize + } + + private static let logger = WhereLog.channel(.model) + + var body: some View { + NavigationStack { + Group { + if let model { + content(model) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .navigationTitle(Strings.regionsManageTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.commonCancel) { dismiss() } + } + if let model, phase == .pick { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.commonSave) { save(model) } + .disabled(!model.hasSelection || isSaving) + } + } + } + } + .task { await loadIfNeeded() } + } + + @ViewBuilder + private func content(_ model: PrimaryRegionSelectionModel) -> some View { + switch phase { + case .pick: + VStack(spacing: stylesheet.spacing.large) { + RegionPickerView(model: model) + + Button { + phase = .customize + } label: { + Text(Strings.regionCustomizeTitle) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(!model.hasSelection) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.large) + } + case .customize: + RegionCustomizeView( + model: model, + onBack: { phase = .pick }, + onFinish: { save(model) }, + ) + } + } + + private func loadIfNeeded() async { + guard model == nil else { return } + do { + let existing = try await session.services.primaryRegions() + model = PrimaryRegionSelectionModel(existing: existing) + } catch { + Self.logger.warning("Failed to load primary regions for editing") + // Fall back to an empty picker rather than a stuck spinner. + model = PrimaryRegionSelectionModel() + } + } + + private func save(_ model: PrimaryRegionSelectionModel) { + guard !isSaving else { return } + isSaving = true + Task { + do { + try await model.commit(using: session) + } catch { + Self.logger.warning("Failed to save primary region edits") + } + dismiss() + } + } +} + +#if DEBUG + #Preview { + RegionsSettingsView() + .environment(PreviewSupport.loadedSession()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index ce727ee3..33b79b30 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -26,6 +26,7 @@ struct SettingsView: View { @State private var showClearConfirmation = false @State private var showResetConfirmation = false @State private var showAppIcon = false + @State private var showRegions = false /// Backup export: the ready-to-share archive built up-front, revealed as a /// `ShareLink` once the background export finishes. @@ -56,6 +57,7 @@ struct SettingsView: View { NavigationStack { Form { trackingSection + regionsSection remindersSection summarySection issueAlertsSection @@ -75,6 +77,9 @@ struct SettingsView: View { .sheet(isPresented: $showAppIcon) { AppIconView() } + .sheet(isPresented: $showRegions) { + RegionsSettingsView() + } .alert(Strings.settingsPermissionAlertTitle, isPresented: $session.permissionDenied) { Button(Strings.settingsPermissionAlertOpenSettings) { openSystemSettings() } Button(Strings.settingsPermissionAlertNotNow, role: .cancel) {} @@ -127,6 +132,22 @@ struct SettingsView: View { } } + private var regionsSection: some View { + Section { + Button { + showRegions = true + } label: { + LabeledContent { + Text(Strings.settingsRegionsRow) + .foregroundStyle(.secondary) + } label: { + Label(Strings.settingsRegionsSection, systemImage: "map.fill") + } + } + .tint(.primary) + } + } + private var trackingSection: some View { @Bindable var session = session return Section { diff --git a/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift b/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift index be954345..8312eb40 100644 --- a/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift +++ b/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift @@ -19,3 +19,15 @@ extension Sequence { map(\.clLocationCoordinate) } } + +extension Coordinate { + /// Build a model `Coordinate` from a MapKit/CoreLocation coordinate — the + /// reverse of ``clLocationCoordinate``, used to hit-test a map tap against + /// `RegionAttributor` (the region picker's map mode). + init(_ clLocationCoordinate: CLLocationCoordinate2D) { + self.init( + latitude: clLocationCoordinate.latitude, + longitude: clLocationCoordinate.longitude, + ) + } +} diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index d2731069..6472e75d 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -332,6 +332,176 @@ enum Strings { String(localized: "onboarding.notNow", defaultValue: "Not Now", bundle: .module) } + static var onboardingBack: String { + String(localized: "onboarding.back", defaultValue: "Back", bundle: .module) + } + + static var onboardingNext: String { + String(localized: "onboarding.next", defaultValue: "Next", bundle: .module) + } + + static var onboardingRegionsTitle: String { + String( + localized: "onboarding.regions.title", + defaultValue: "Where do you spend your time?", + bundle: .module, + ) + } + + static var onboardingLocationTitle: String { + String( + localized: "onboarding.location.title", + defaultValue: "Turn on location", + bundle: .module, + ) + } + + static var onboardingLocationDescription: String { + String( + localized: "onboarding.location.description", + defaultValue: "Where uses background location to log the regions you pass through. You can change this anytime in Settings.", + bundle: .module, + ) + } + + // MARK: Region picker + + static var regionPickerTitle: String { + String(localized: "regionPicker.title", defaultValue: "Your regions", bundle: .module) + } + + static var regionPickerSubtitle: String { + String( + localized: "regionPicker.subtitle", + defaultValue: "Pick up to 5 regions where you spend your time.", + bundle: .module, + ) + } + + static var regionPickerModeMap: String { + String(localized: "regionPicker.mode.map", defaultValue: "Map", bundle: .module) + } + + static var regionPickerModeList: String { + String(localized: "regionPicker.mode.list", defaultValue: "List", bundle: .module) + } + + /// Accessibility label for the map/list mode switch. + static var regionPickerModePicker: String { + String(localized: "regionPicker.mode.picker", defaultValue: "View", bundle: .module) + } + + static var regionPickerSearchPrompt: String { + String( + localized: "regionPicker.search.prompt", + defaultValue: "Search regions", + bundle: .module, + ) + } + + /// "2 of 5 selected". + static func regionPickerSelectionCount(selected: Int, max: Int) -> String { + String( + localized: "regionPicker.selectionCount", + defaultValue: "\(selected) of \(max) selected", + bundle: .module, + ) + } + + static var regionPickerMapAccessibility: String { + String( + localized: "regionPicker.map.accessibility", + defaultValue: "Map for picking your regions", + bundle: .module, + ) + } + + static var regionPickerEmptyTitle: String { + String( + localized: "regionPicker.empty.title", + defaultValue: "No matching regions", + bundle: .module, + ) + } + + static var regionPickerLoadErrorTitle: String { + String( + localized: "regionPicker.loadError.title", + defaultValue: "Couldn't load the map", + bundle: .module, + ) + } + + // MARK: Region customization + + static var regionCustomizeTitle: String { + String(localized: "regionCustomize.title", defaultValue: "Make it yours", bundle: .module) + } + + /// "Choose a look for California". + static func regionCustomizeSubtitle(region: String) -> String { + String( + localized: "regionCustomize.subtitle", + defaultValue: "Choose a look for \(region)", + bundle: .module, + ) + } + + static var regionCustomizeColor: String { + String(localized: "regionCustomize.color", defaultValue: "Color", bundle: .module) + } + + static var regionCustomizeEmoji: String { + String(localized: "regionCustomize.emoji", defaultValue: "Emoji", bundle: .module) + } + + static var regionCustomizeSymbol: String { + String(localized: "regionCustomize.symbol", defaultValue: "Icon", bundle: .module) + } + + /// "Region 2 of 5". + static func regionCustomizeStep(current: Int, total: Int) -> String { + String( + localized: "regionCustomize.step", + defaultValue: "Region \(current) of \(total)", + bundle: .module, + ) + } + + static func regionColorAccessibility(_ token: RegionColorToken) -> String { + String( + localized: "regionCustomize.color.accessibility", + defaultValue: "Color \(token.rawValue)", + bundle: .module, + ) + } + + // MARK: Region management (Settings) + + static var regionsManageTitle: String { + String(localized: "regions.manage.title", defaultValue: "Your regions", bundle: .module) + } + + static var settingsRegionsSection: String { + String(localized: "settings.regions.section", defaultValue: "Regions", bundle: .module) + } + + static var settingsRegionsRow: String { + String( + localized: "settings.regions.row", + defaultValue: "Primary regions", + bundle: .module, + ) + } + + static var settingsRegionsEmpty: String { + String(localized: "settings.regions.empty", defaultValue: "None yet", bundle: .module) + } + + static var commonSave: String { + String(localized: "common.save", defaultValue: "Save", bundle: .module) + } + // MARK: Migration static var migrationTitle: String { diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 033cf336..2cacd58d 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -19,6 +19,7 @@ struct WhereStylesheet: BStylesheet { var appIcon = AppIconStyle.standard var timeline = TimelineStyle.standard var regionMap = RegionMapStyle.standard + var regionPicker = RegionPickerStyle.standard var evidence = EvidenceStyle.standard var palette = Palette.standard var motion = Motion.standard @@ -455,6 +456,69 @@ extension WhereStylesheet { } } +// MARK: - Region picker / customization + +extension WhereStylesheet { + /// Style for the primary-region picker (`RegionPickerView`) and per-region + /// customization (`RegionAppearanceEditor`): the selectable-state map fills, + /// the color swatch and emoji/symbol tile geometry, and the default US map + /// framing. Generic spacing (grid gaps, section stacks) still comes from + /// ``Spacing``. The camera is stored as raw degrees so the stylesheet stays + /// MapKit-free; the view assembles the `MKCoordinateRegion`. + struct RegionPickerStyle: Equatable { + /// Corner radius of the map's rounded container. + var mapCornerRadius: CGFloat + /// Polygon fill opacity for a selected vs unselected state. + var selectedFillOpacity: Double + var unselectedFillOpacity: Double + /// Polygon stroke opacity + width for a selected vs unselected state. + var selectedStrokeOpacity: Double + var unselectedStrokeOpacity: Double + var selectedStrokeWidth: CGFloat + var unselectedStrokeWidth: CGFloat + /// Diameter of a color swatch and the width of its selection ring. + var colorSwatchSize: CGFloat + var colorSwatchSelectionRing: CGFloat + /// Minimum grid cell width for the color swatches. + var colorSwatchMinWidth: CGFloat + /// Edge of an emoji/symbol tile and its minimum grid cell width. + var glyphTileSize: CGFloat + var glyphTileMinWidth: CGFloat + /// Corner radius and selection stroke width of a glyph tile. + var glyphCornerRadius: CGFloat + var glyphSelectionStrokeWidth: CGFloat + /// Background tint opacity of a selected glyph tile. + var glyphSelectedBackgroundOpacity: Double + /// Default map camera framing the contiguous US (raw degrees). + var mapCenterLatitude: Double + var mapCenterLongitude: Double + var mapSpanLatitude: Double + var mapSpanLongitude: Double + + static let standard = RegionPickerStyle( + mapCornerRadius: 12, + selectedFillOpacity: 0.55, + unselectedFillOpacity: 0.12, + selectedStrokeOpacity: 0.9, + unselectedStrokeOpacity: 0.35, + selectedStrokeWidth: 2, + unselectedStrokeWidth: 1, + colorSwatchSize: 40, + colorSwatchSelectionRing: 3, + colorSwatchMinWidth: 44, + glyphTileSize: 48, + glyphTileMinWidth: 52, + glyphCornerRadius: 6, + glyphSelectionStrokeWidth: 2, + glyphSelectedBackgroundOpacity: 0.2, + mapCenterLatitude: 39.5, + mapCenterLongitude: -98.35, + mapSpanLatitude: 45, + mapSpanLongitude: 55, + ) + } +} + // MARK: - Evidence extension WhereStylesheet { diff --git a/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift new file mode 100644 index 00000000..733dd827 --- /dev/null +++ b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift @@ -0,0 +1,113 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// The primary-region picker model: US-only options, the pick cap, ordered +/// selection, and appearance drafts. +@MainActor +struct PrimaryRegionSelectionModelTests { + private func region(_ id: String) throws -> Region { + try #require(Region(rawValue: id)) + } + + @Test func offersUSRegionsOnly() { + let model = PrimaryRegionSelectionModel() + #expect(!model.available.isEmpty) + #expect(model.available.allSatisfy { $0.rawValue.hasPrefix("us-") }) + } + + @Test func toggleAddsAndRemovesPreservingOrder() throws { + let model = PrimaryRegionSelectionModel() + let tx = try region("us-TX") + model.toggle(.california) + model.toggle(.newYork) + model.toggle(tx) + #expect(model.selectedRegions == [.california, .newYork, tx]) + + model.toggle(.newYork) + #expect(model.selectedRegions == [.california, tx]) + } + + @Test func cannotSelectMoreThanTheCap() { + let model = PrimaryRegionSelectionModel() + // Pick the first maxSelection available regions, then attempt one more. + let picks = Array(model.available.prefix(PrimaryRegionSelectionModel.maxSelection)) + for region in picks { + model.toggle(region) + } + #expect(model.isAtCapacity) + + let extra = model.available[PrimaryRegionSelectionModel.maxSelection] + #expect(!model.canToggle(extra)) + model.toggle(extra) + // The add past the cap is a no-op. + #expect(model.selectionCount == PrimaryRegionSelectionModel.maxSelection) + #expect(!model.isSelected(extra)) + + // An already-selected region can still be toggled off at capacity. + #expect(model.canToggle(picks[0])) + } + + @Test func appearanceDraftsDefaultThenUpdate() { + let model = PrimaryRegionSelectionModel() + let start = model.appearance(for: .california) + #expect(start == RegionAppearanceCatalog.defaultAppearance(for: .california)) + + model.setColor(.mint, for: .california) + model.setEmoji("🌊", for: .california) + model.setSymbol("water.waves", for: .california) + let edited = model.appearance(for: .california) + #expect(edited.color == .mint) + #expect(edited.emoji == "🌊") + #expect(edited.symbolName == "water.waves") + } + + @Test func seedsFromExistingPrimaryRegions() throws { + let tx = try region("us-TX") + let look = RegionAppearance(color: .orange, emoji: "🤠", symbolName: "star.fill") + let model = PrimaryRegionSelectionModel(existing: [ + PrimaryRegion(region: .california, appearance: nil, order: 0), + PrimaryRegion(region: tx, appearance: look, order: 1), + ]) + #expect(model.selectedRegions == [.california, tx]) + #expect(model.appearance(for: tx) == look) + } + + @Test func commitPersistsPicksAsTrackedRegionsWithAppearance() async throws { + let session = PreviewSupport.loadedSession() + let model = PrimaryRegionSelectionModel() + model.toggle(.california) + model.setColor(.orange, for: .california) + model.setEmoji("🌴", for: .california) + + try await model.commit(using: session) + + let primary = try await session.services.primaryRegions() + #expect(primary.map(\.region) == [.california]) + #expect(primary.first?.appearance?.emoji == "🌴") + #expect(try await session.services.trackedRegions() == [.california]) + } + + @Test func commitUntracksRemovedRegions() async throws { + let session = PreviewSupport.loadedSession() + // Seed the store with two picks, then commit an edit that drops one. + try await session.services.setPrimaryRegion( + RegionAppearanceCatalog.defaultAppearance(for: .california), + id: Region.california.rawValue, + order: 0, + ) + try await session.services.setPrimaryRegion( + RegionAppearanceCatalog.defaultAppearance(for: .newYork), + id: Region.newYork.rawValue, + order: 1, + ) + + let existing = try await session.services.primaryRegions() + let model = PrimaryRegionSelectionModel(existing: existing) + model.toggle(.newYork) // remove NY + try await model.commit(using: session) + + #expect(try await session.services.trackedRegions() == [.california]) + } +} diff --git a/Where/WhereUI/Tests/RegionStyleRegistryTests.swift b/Where/WhereUI/Tests/RegionStyleRegistryTests.swift new file mode 100644 index 00000000..11b06bf6 --- /dev/null +++ b/Where/WhereUI/Tests/RegionStyleRegistryTests.swift @@ -0,0 +1,52 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// `RegionStyle.style(for:)` resolves a picked appearance through +/// `RegionStyleRegistry`, falling back to the deterministic default look. +struct RegionStyleRegistryTests { + @Test func pickedAppearanceWinsOverFallback() { + let registry = RegionStyleRegistry() + let look = RegionAppearance(color: .pink, emoji: "🎸", symbolName: "star.fill") + registry.replaceAll([.california: look]) + + #expect(registry.appearance(for: .california) == look) + // A region with no entry resolves to nil (the caller uses its fallback). + #expect(registry.appearance(for: .newYork) == nil) + + let style = RegionStyle(look) + #expect(style.emoji == "🎸") + #expect(style.symbolName == "star.fill") + } + + @Test func replaceAllFromPrimaryRegionsKeepsOnlyResolvedLooks() { + let registry = RegionStyleRegistry() + let look = RegionAppearance(color: .teal, emoji: "🌊", symbolName: "water.waves") + registry.replaceAll(from: [ + PrimaryRegion(region: .california, appearance: look, order: 0), + PrimaryRegion(region: .newYork, appearance: nil, order: 1), + ]) + + #expect(registry.appearance(for: .california) == look) + #expect(registry.appearance(for: .newYork) == nil) + } + + @Test func replaceAllRemovesStalePicks() { + let registry = RegionStyleRegistry() + let look = RegionAppearance(color: .red, emoji: "🍁", symbolName: "leaf.fill") + registry.replaceAll([.canada: look]) + // A wholesale swap drops a region that's no longer primary. + registry.replaceAll([:]) + #expect(registry.appearance(for: .canada) == nil) + } + + @Test func defaultAppearanceMatchesSharedCatalog() { + // The customization pre-fill and the RegionStyle fallback share one + // source, so a region with no pick renders its catalog default. + let expected = RegionAppearanceCatalog.defaultAppearance(for: .california) + let fallback = RegionStyle.fallbackStyle(for: .california) + #expect(fallback.emoji == expected.emoji) + #expect(fallback.symbolName == expected.symbolName) + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index d8bafb83..2ad9c6b0 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -173,6 +173,29 @@ struct WhereStylesheetTests { #expect(regionMap.uncertaintyStrokeWidth == 1) } + @Test func regionPickerStyle() { + let picker = style.regionPicker + #expect(picker.mapCornerRadius == 12) + #expect(picker.selectedFillOpacity == 0.55) + #expect(picker.unselectedFillOpacity == 0.12) + #expect(picker.selectedStrokeOpacity == 0.9) + #expect(picker.unselectedStrokeOpacity == 0.35) + #expect(picker.selectedStrokeWidth == 2) + #expect(picker.unselectedStrokeWidth == 1) + #expect(picker.colorSwatchSize == 40) + #expect(picker.colorSwatchSelectionRing == 3) + #expect(picker.colorSwatchMinWidth == 44) + #expect(picker.glyphTileSize == 48) + #expect(picker.glyphTileMinWidth == 52) + #expect(picker.glyphCornerRadius == 6) + #expect(picker.glyphSelectionStrokeWidth == 2) + #expect(picker.glyphSelectedBackgroundOpacity == 0.2) + #expect(picker.mapCenterLatitude == 39.5) + #expect(picker.mapCenterLongitude == -98.35) + #expect(picker.mapSpanLatitude == 45) + #expect(picker.mapSpanLongitude == 55) + } + @Test func evidenceStyle() { let evidence = style.evidence #expect(evidence.previewCornerRadius == 22) diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index cb61a610..7ebb731d 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -1,5 +1,6 @@ import LogKit import WhereCore +import WhereUI import WidgetKit struct WhereWidgetEntry: TimelineEntry { @@ -50,6 +51,11 @@ struct WhereWidgetProvider: TimelineProvider { do { let store = try WidgetSnapshotStore.shared() if let snapshot = store.read() { + // The widget process has no `WhereSession` to seed the styling + // registry from the store, so feed it the picked appearances the + // snapshot carries — this is what makes `region.style` render the + // user's chosen color/emoji/icon in the widget. + RegionStyleRegistry.shared.replaceAll(snapshot.appearances) return WhereWidgetEntry(date: now, snapshot: snapshot) } Self.logger.warning("No published widget snapshot; rendering empty state") From 571ddd968709b13931d819df2dfe6a85a515f334 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 20:11:19 -0700 Subject: [PATCH 02/14] Put region picker/customize in nav stacks with toolbar controls Address code-review finding #1 (onboarding List had no search field because .searchable needs a navigation container) and make the onboarding and Settings region flows visually consistent: - Onboarding pick phase is a NavigationStack with a large title and a top-right Next (forward-only after the intro); its list is now filterable via .searchable. - RegionCustomizeView moves Back/Next (Done on the last region) into the nav bar with the region name as the inline title and an X-of-N indicator; callers place it in a NavigationStack. - Onboarding customize phase wraps it in a NavigationStack. - RegionsSettingsView: pick shows Cancel + Next (large title, matching onboarding), customize delegates to RegionCustomizeView's toolbar; dropped the old bottom buttons so each phase owns its own bar. --- .../Sources/Resources/Localizable.xcstrings | 134 +- .../Resources/Localizable.xcstrings | 17 +- .../Sources/Onboarding/OnboardingView.swift | 45 +- .../Sources/Regions/RegionCustomizeView.swift | 46 +- .../Sources/Regions/RegionsSettingsView.swift | 46 +- .../Sources/Resources/Localizable.xcstrings | 1301 ++++++++++++++--- 6 files changed, 1304 insertions(+), 285 deletions(-) diff --git a/Where/WhereIntents/Sources/Resources/Localizable.xcstrings b/Where/WhereIntents/Sources/Resources/Localizable.xcstrings index 8a52928a..ee0ca51a 100644 --- a/Where/WhereIntents/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereIntents/Sources/Resources/Localizable.xcstrings @@ -1,6 +1,10 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + "comment" : "A spoken dialog response for the \"Count days in a region\" intent. The argument is the region name, the second is the number of days, and the third is the year.", + "isCommentAutoGenerated" : true + }, "audit.note.siri" : { "comment" : "Note stored on a manual day entry made through an intent.", "localizations" : { @@ -12,6 +16,26 @@ } } }, + "Backfill a range of days with the regions you were in." : { + "comment" : "Description of the Log Trip intent.", + "isCommentAutoGenerated" : true + }, + "Count Days in a Region" : { + "comment" : "Intent name for the \"Count Days in a Region\" action.", + "isCommentAutoGenerated" : true + }, + "Date" : { + "comment" : "Label for the date parameter in the RegionOnDateIntent.", + "isCommentAutoGenerated" : true + }, + "Day" : { + "comment" : "The name of the parameter for the day of the week.", + "isCommentAutoGenerated" : true + }, + "Days in a Region" : { + "comment" : "Title of the Days in a Region snippet.", + "isCommentAutoGenerated" : true + }, "dialog.daysInRegion.none" : { "comment" : "Spoken result when a region has no logged days this year. %1$@ region name, %2$@ year.", "localizations" : { @@ -98,75 +122,75 @@ } } }, - "dialog.regionOnDate.none" : { - "comment" : "Spoken result when a date has nothing logged. %1$@ formatted date.", + "dialog.recentActivity.empty" : { + "comment" : "Spoken result when a window has no tracked activity. %1$@ window phrase.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nothing is logged for %1$@." + "value" : "Nothing was tracked in %1$@." } } } }, - "dialog.regionOnDate.some" : { - "comment" : "Spoken result naming the regions a date counted for. %1$@ date, %2$@ region list.", + "dialog.recentActivity.unavailable.appleIntelligenceNotEnabled" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On %1$@ you were in %2$@." + "value" : "Turn on Apple Intelligence in Settings to summarize where you've been." } } } }, - "dialog.recentActivity.empty" : { - "comment" : "Spoken result when a window has no tracked activity. %1$@ window phrase.", + "dialog.recentActivity.unavailable.deviceNotEligible" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nothing was tracked in %1$@." + "value" : "This device doesn't support on-device summaries." } } } }, - "dialog.recentActivity.unavailable.appleIntelligenceNotEnabled" : { + "dialog.recentActivity.unavailable.modelNotReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Turn on Apple Intelligence in Settings to summarize where you've been." + "value" : "The on-device model is still getting ready. Try again shortly." } } } }, - "dialog.recentActivity.unavailable.deviceNotEligible" : { + "dialog.recentActivity.unavailable.unknown" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "This device doesn't support on-device summaries." + "value" : "On-device summaries aren't available right now." } } } }, - "dialog.recentActivity.unavailable.modelNotReady" : { + "dialog.regionOnDate.none" : { + "comment" : "Spoken result when a date has nothing logged. %1$@ formatted date.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The on-device model is still getting ready. Try again shortly." + "value" : "Nothing is logged for %1$@." } } } }, - "dialog.recentActivity.unavailable.unknown" : { + "dialog.regionOnDate.some" : { + "comment" : "Spoken result naming the regions a date counted for. %1$@ date, %2$@ region list.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On-device summaries aren't available right now." + "value" : "On %1$@ you were in %2$@." } } } @@ -232,6 +256,61 @@ } } }, + "End Date" : { + "comment" : "Label for the end date parameter in the Log a Trip intent.", + "isCommentAutoGenerated" : true + }, + "Find out how many days you've spent in a region so far this year." : { + + }, + "Find Regions on a Date" : { + "comment" : "Title of the intent.", + "isCommentAutoGenerated" : true + }, + "Get an on-device summary of where you've been over a recent window." : { + "comment" : "Description of the intent to get an on-device summary of where the user has been over a recent window.", + "isCommentAutoGenerated" : true + }, + "Last 24 Hours" : { + "comment" : "Display name for the \"Last 24 Hours\" option in the Siri-resolved menu.", + "isCommentAutoGenerated" : true + }, + "Log a Day's Regions" : { + "comment" : "Intent title.", + "isCommentAutoGenerated" : true + }, + "Log a Trip" : { + + }, + "Look up which regions a particular day counted for." : { + + }, + "Past Month" : { + + }, + "Past Week" : { + "comment" : "Displayed title for the \"Past Week\" option in the Siri-resolved menu.", + "isCommentAutoGenerated" : true + }, + "Record which regions you were in on a day." : { + + }, + "Region" : { + "comment" : "The parameter name for the region parameter in the DaysInRegionIntent.", + "isCommentAutoGenerated" : true + }, + "Regions" : { + "comment" : "The parameter title for the regions parameter in the Log Day intent.", + "isCommentAutoGenerated" : true + }, + "See which regions today counts for so far." : { + "comment" : "Description of the \"Show Today's Regions\" intent.", + "isCommentAutoGenerated" : true + }, + "Show Today's Regions" : { + "comment" : "Title of the \"Where am I today?\" intent.", + "isCommentAutoGenerated" : true + }, "snippet.logTodayHere" : { "comment" : "Button in the day-count snippet that logs today for the shown region.", "localizations" : { @@ -242,7 +321,24 @@ } } } + }, + "Start Date" : { + + }, + "Summarize Recent Activity" : { + "comment" : "Intent to summarize recent activity.", + "isCommentAutoGenerated" : true + }, + "Time Range" : { + + }, + "Year" : { + + }, + "Year So Far" : { + "comment" : "Display name for the \"Year So Far\" option in the", + "isCommentAutoGenerated" : true } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Where/WhereShareExtension/Resources/Localizable.xcstrings b/Where/WhereShareExtension/Resources/Localizable.xcstrings index 045b4e95..4dc9a857 100644 --- a/Where/WhereShareExtension/Resources/Localizable.xcstrings +++ b/Where/WhereShareExtension/Resources/Localizable.xcstrings @@ -3,6 +3,7 @@ "strings" : { "share.attachment.fallbackName" : { "comment" : "Stand-in name for a shared attachment the provider gave no filename for.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -14,6 +15,7 @@ }, "share.attachment.header" : { "comment" : "Section header above the shared attachment summary (one item).", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -25,6 +27,7 @@ }, "share.attachment.headerPlural" : { "comment" : "Section header above the shared attachments (more than one item).", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -36,6 +39,7 @@ }, "share.attachment.none" : { "comment" : "Shown when the share carried nothing loadable, so only metadata is saved.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -47,6 +51,7 @@ }, "share.cancel" : { "comment" : "Cancel button dismissing the share sheet without saving.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -58,6 +63,7 @@ }, "share.form.date" : { "comment" : "Date picker label for when the evidence was captured.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -69,6 +75,7 @@ }, "share.form.kind" : { "comment" : "Picker label for the evidence kind.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -80,6 +87,7 @@ }, "share.form.noteHeader" : { "comment" : "Section header above the optional note field.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -91,6 +99,7 @@ }, "share.form.notePlaceholder" : { "comment" : "Placeholder for the optional note text field.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -102,6 +111,7 @@ }, "share.form.otherLabel" : { "comment" : "Placeholder for the free-text label shown when the kind is Other.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -113,6 +123,7 @@ }, "share.loading" : { "comment" : "Progress label while the shared attachment is being read.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -124,6 +135,7 @@ }, "share.ok" : { "comment" : "Dismiss button on the save-failure alert.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -135,6 +147,7 @@ }, "share.save" : { "comment" : "Save button persisting the shared evidence.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -146,6 +159,7 @@ }, "share.saveError.title" : { "comment" : "Title of the alert shown when saving the shared evidence fails.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -157,6 +171,7 @@ }, "share.title" : { "comment" : "Navigation title of the share compose sheet.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -168,4 +183,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index ca13444f..1254b4fe 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -127,43 +127,28 @@ public struct OnboardingView: View { // MARK: - Pick regions private var pickRegions: some View { - VStack(spacing: stylesheet.spacing.large) { - VStack(spacing: stylesheet.spacing.small) { - Text(Strings.onboardingRegionsTitle) - .font(.title.bold()) - .multilineTextAlignment(.center) - Text(Strings.regionPickerSubtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.top, stylesheet.spacing.xxxLarge) - + NavigationStack { RegionPickerView(model: selection) - - Button { - phase = .customize - } label: { - Text(Strings.onboardingNext) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(!selection.hasSelection) - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.bottom, stylesheet.spacing.xxxLarge) + .navigationTitle(Strings.onboardingRegionsTitle) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.onboardingNext) { phase = .customize } + .disabled(!selection.hasSelection) + } + } } } // MARK: - Customize private var customize: some View { - RegionCustomizeView( - model: selection, - onBack: { phase = .pickRegions }, - onFinish: { phase = .location }, - ) + NavigationStack { + RegionCustomizeView( + model: selection, + onBack: { phase = .pickRegions }, + onFinish: { phase = .location }, + ) + } } // MARK: - Location diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift index 4920f8ef..00b3330b 100644 --- a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -155,10 +155,11 @@ struct RegionAppearanceEditor: View { } /// Steps through each picked region in pick order, editing its look with -/// ``RegionAppearanceEditor``, with Back/Next controls. `onFinish` fires when -/// the user advances past the last region; `onBack` fires when they go back -/// before the first (so the onboarding flow can return to the picker). Used by -/// onboarding; Settings edits a single region with the editor directly. +/// ``RegionAppearanceEditor``. Back/Next (Done on the last region) live in the +/// navigation bar, so the caller must place this inside a `NavigationStack`. +/// `onFinish` fires when the user advances past the last region; `onBack` fires +/// when they go back before the first (so onboarding returns to the picker and +/// the Settings editor returns to its pick phase). struct RegionCustomizeView: View { @Bindable var model: PrimaryRegionSelectionModel var onBack: () -> Void = {} @@ -173,18 +174,11 @@ struct RegionCustomizeView: View { } var body: some View { - VStack(spacing: 0) { + Group { if let region = currentRegion { - Text(Strings.regionCustomizeStep(current: index + 1, total: regions.count)) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.top, stylesheet.spacing.small) - RegionAppearanceEditor(model: model, region: region) .id(region) .transition(.opacity) - - controls } else { // No selection to customize — nothing to step through. ContentUnavailableView( @@ -195,25 +189,27 @@ struct RegionCustomizeView: View { } } .animation(stylesheet.motion.captionFade, value: index) - .navigationTitle(Strings.regionCustomizeTitle) + .navigationTitle(currentRegion?.localizedName ?? Strings.regionCustomizeTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(Strings.onboardingBack, action: goBack) + } + ToolbarItem(placement: .principal) { + Text(Strings.regionCustomizeStep(current: index + 1, total: regions.count)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + ToolbarItem(placement: .confirmationAction) { + Button(isLast ? Strings.commonDone : Strings.onboardingNext, action: goNext) + } + } } private var currentRegion: Region? { regions.indices.contains(index) ? regions[index] : nil } - private var controls: some View { - HStack(spacing: stylesheet.spacing.large) { - Button(Strings.onboardingBack, action: goBack) - .buttonStyle(.bordered) - - Button(isLast ? Strings.commonDone : Strings.onboardingNext, action: goNext) - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - } - .padding(stylesheet.spacing.large) - } - private var isLast: Bool { index >= regions.count - 1 } diff --git a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift index 223c4bc7..7eaf9a58 100644 --- a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift +++ b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift @@ -8,7 +8,6 @@ import WhereCore struct RegionsSettingsView: View { @Environment(WhereSession.self) private var session @Environment(\.dismiss) private var dismiss - @Environment(\.stylesheet) private var stylesheet /// The picker/customization model, built once the current picks load. @State private var model: PrimaryRegionSelectionModel? @@ -30,19 +29,12 @@ struct RegionsSettingsView: View { } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - .navigationTitle(Strings.regionsManageTitle) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(Strings.commonCancel) { dismiss() } - } - if let model, phase == .pick { - ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonSave) { save(model) } - .disabled(!model.hasSelection || isSaving) - } + .navigationTitle(Strings.regionsManageTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.commonCancel) { dismiss() } + } + } } } } @@ -53,22 +45,20 @@ struct RegionsSettingsView: View { private func content(_ model: PrimaryRegionSelectionModel) -> some View { switch phase { case .pick: - VStack(spacing: stylesheet.spacing.large) { - RegionPickerView(model: model) - - Button { - phase = .customize - } label: { - Text(Strings.regionCustomizeTitle) - .frame(maxWidth: .infinity) + RegionPickerView(model: model) + .navigationTitle(Strings.regionsManageTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.commonCancel) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(Strings.onboardingNext) { phase = .customize } + .disabled(!model.hasSelection) + } } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(!model.hasSelection) - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.bottom, stylesheet.spacing.large) - } case .customize: + // `RegionCustomizeView` supplies its own Back/Done toolbar; Back + // returns to the pick phase, Done saves. RegionCustomizeView( model: model, onBack: { phase = .pick }, diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 086e6e37..23299d1f 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -166,6 +166,19 @@ } } }, + "calendar.day.hasEvidence.accessibility" : { + "comment" : "Accessibility label for a calendar day that has evidence.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@, has evidence" + } + } + } + }, "calendar.day.needsAttention.accessibility" : { "comment" : "Accessibility label for a calendar day that needs attention.", "extractionState" : "extracted_with_value", @@ -218,6 +231,17 @@ } } }, + "common.cancel" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, "common.day" : { "extractionState" : "manual", "localizations" : { @@ -301,21 +325,717 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "%1$@: %2$@" + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "common.save" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "developer.button.label" : { + "comment" : "Accessibility label for the floating developer button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer tools" + } + } + } + }, + "developer.close" : { + "comment" : "Accessibility label for closing the developer panel.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "developer.collapse" : { + "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit full screen" + } + } + } + }, + "developer.expand" : { + "comment" : "Accessibility label for growing the developer panel to full screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter full screen" + } + } + } + }, + "developer.footer" : { + "comment" : "Footer for the developer tools list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device logs and data tools. Debug builds only." + } + } + } + }, + "developer.inspectorLink" : { + "comment" : "Label for the navigation link that opens the SwiftData inspector.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftData Inspector" + } + } + } + }, + "developer.inspectorTitle" : { + "comment" : "Navigation title for the SwiftData inspector screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftData" + } + } + } + }, + "developer.logsLink" : { + "comment" : "Label for the button that opens the logs screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, + "developer.logsTitle" : { + "comment" : "Title of the screen that shows the user's logs.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, + "developer.regionMapLink" : { + "comment" : "Label for the navigation link that opens the region map.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region map" + } + } + } + }, + "developer.title" : { + "comment" : "Navigation title of the developer tools list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer" + } + } + } + }, + "evidence.add" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add evidence" + } + } + } + }, + "evidence.detail.noAttachment" : { + "comment" : "Label for an evidence item that doesn't have an attached file.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No attachment" + } + } + } + }, + "evidence.detail.noPreview.description" : { + "comment" : "Description of an error message when an attachment can't be previewed.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This attachment can't be previewed here." + } + } + } + }, + "evidence.detail.noPreview.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No preview" + } + } + } + }, + "evidence.detail.noteHeader" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "evidence.detail.previewFailed" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load this attachment." + } + } + } + }, + "evidence.empty.description" : { + "comment" : "Description of the empty state when there is no evidence.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Attach a boarding pass, receipt, or screenshot to back up where you were. Add one here, or share it into Where from another app." + } + } + } + }, + "evidence.empty.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No evidence yet" + } + } + } + }, + "evidence.failed.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load evidence" + } + } + } + }, + "evidence.form.attachmentHeader" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Attachment" + } + } + } + }, + "evidence.form.chooseFile" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose file" + } + } + } + }, + "evidence.form.choosePhoto" : { + "comment" : "Label for choosing a photo from the photo library.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose photo" + } + } + } + }, + "evidence.form.date" : { + "comment" : "Label for the date picker in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date" + } + } + } + }, + "evidence.form.kind" : { + "comment" : "Label for the \"Kind\" field in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Kind" + } + } + } + }, + "evidence.form.note" : { + "comment" : "Label for the note field in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "evidence.form.notePlaceholder" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add a note (optional)" + } + } + } + }, + "evidence.form.otherLabel" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + } + } + }, + "evidence.form.remove" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove attachment" + } + } + } + }, + "evidence.form.save" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "evidence.form.saveError.title" : { + "comment" : "Title of an alert when an evidence form fails to save.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't save evidence" + } + } + } + }, + "evidence.kind.boardingPass" : { + "comment" : "\"Boarding pass\" is a generic term for a travel document that shows a passenger's name, destination, and departure/arrival times.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Boarding pass" + } + } + } + }, + "evidence.kind.carRental" : { + "comment" : "The display name for a \"car rental\" evidence kind.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Car rental" + } + } + } + }, + "evidence.kind.document" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Document" + } + } + } + }, + "evidence.kind.email" : { + "comment" : "\"Email\" is a generic term for an", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Email" + } + } + } + }, + "evidence.kind.hotelReceipt" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hotel receipt" + } + } + } + }, + "evidence.kind.other" : { + "comment" : "A generic label for an \"other\" evidence kind.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Other" + } + } + } + }, + "evidence.kind.photo" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Photo" + } + } + } + }, + "evidence.kind.planeTicket" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Plane ticket" + } + } + } + }, + "evidence.kind.rideshare" : { + "comment" : "\"Rideshare\" is a generic term for", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rideshare" + } + } + } + }, + "evidence.list.title" : { + "comment" : "Title of the evidence list, including the year.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Evidence · %@" + } + } + } + }, + "evidence.row.accessibility" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@" + } + } + } + }, + "launch.accessibilityLabel" : { + "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading" + } + } + } + }, + "loggedDays.add" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log a day" + } + } + } + }, + "loggedDays.delete" : { + "comment" : "Label for the destructive button to delete a logged day.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete entry" + } + } + } + }, + "loggedDays.delete.footer" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Removes this manual entry and restores the day's GPS-detected location." + } + } + } + }, + "loggedDays.deleteError.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't delete entry" + } + } + } + }, + "loggedDays.edit.date" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Day" + } + } + } + }, + "loggedDays.edit.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit day" + } + } + } + }, + "loggedDays.empty.description" : { + "comment" : "Description of the empty state when there are no logged days.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Backfill a trip the GPS missed, or correct a day by hand — your manual entries for this year show up here." + } + } + } + }, + "loggedDays.empty.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No logged days" + } + } + } + }, + "loggedDays.failed.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load logged days" + } + } + } + }, + "loggedDays.filter.all" : { + "comment" : "Label for the \"All\" filter segment in the logged days view.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All" + } + } + } + }, + "loggedDays.filter.label" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Filter" + } + } + } + }, + "loggedDays.kind.logged" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged" + } + } + } + }, + "loggedDays.kind.overridden" : { + "comment" : "Label for a logged-day row that's been manually overridden.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Overridden" + } + } + } + }, + "loggedDays.noMatches.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No days match this filter." + } + } + } + }, + "loggedDays.noMatches.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No matching days" } } } }, - "launch.accessibilityLabel" : { - "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", + "loggedDays.title" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Loading" + "value" : "Logged Days · %@" } } } @@ -670,6 +1390,17 @@ } } }, + "onboarding.back" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + } + } + }, "onboarding.continue" : { "comment" : "Button text for continuing to the next onboarding step.", "extractionState" : "extracted_with_value", @@ -696,6 +1427,41 @@ } } }, + "onboarding.location.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where uses background location to log the regions you pass through. You can change this anytime in Settings." + } + } + } + }, + "onboarding.location.title" : { + "comment" : "Title of a screen in the onboarding flow that asks the user to turn on location.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Turn on location" + } + } + } + }, + "onboarding.next" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next" + } + } + } + }, "onboarding.notNow" : { "comment" : "Button title for skipping the onboarding flow.", "extractionState" : "extracted_with_value", @@ -735,6 +1501,17 @@ } } }, + "onboarding.regions.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where do you spend your time?" + } + } + } + }, "onboarding.welcome.description" : { "comment" : "A longer description of Where, for the onboarding welcome screen.", "extractionState" : "extracted_with_value", @@ -843,6 +1620,17 @@ } } }, + "primary.evidence" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Evidence" + } + } + } + }, "primary.loading" : { "extractionState" : "manual", "localizations" : { @@ -854,6 +1642,17 @@ } } }, + "primary.loggedDays" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged days" + } + } + } + }, "primary.recentActivity" : { "extractionState" : "manual", "localizations" : { @@ -1162,6 +1961,85 @@ } } }, + "regionCustomize.color" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Color" + } + } + } + }, + "regionCustomize.color.accessibility" : { + "comment" : "Accessibility label for a region color.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Color %@" + } + } + } + }, + "regionCustomize.emoji" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emoji" + } + } + } + }, + "regionCustomize.step" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Region %1$lld of %2$lld" + } + } + } + }, + "regionCustomize.subtitle" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose a look for %@" + } + } + } + }, + "regionCustomize.symbol" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Icon" + } + } + } + }, + "regionCustomize.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make it yours" + } + } + } + }, "regionMap.empty.description" : { "extractionState" : "manual", "localizations" : { @@ -1173,123 +2051,246 @@ } } }, - "regionMap.empty.title" : { - "extractionState" : "manual", + "regionMap.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No regions" + } + } + } + }, + "regionMap.kind.attribution" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attribution" + } + } + } + }, + "regionMap.kind.attribution.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The simplified polygons the app uses to attribute coordinates today." + } + } + } + }, + "regionMap.kind.picker" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geometry" + } + } + } + }, + "regionMap.kind.source" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source" + } + } + } + }, + "regionMap.kind.source.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity." + } + } + } + }, + "regionMap.legend.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Features" + } + } + } + }, + "regionMap.loadError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load regions" + } + } + } + }, + "regionMap.map.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Map of region boundaries" + } + } + } + }, + "regionMap.showAll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show all" + } + } + } + }, + "regionMap.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region Map" + } + } + } + }, + "regionPicker.empty.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "No regions" + "state" : "new", + "value" : "No matching regions" } } } }, - "regionMap.kind.attribution" : { - "extractionState" : "manual", + "regionPicker.loadError.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Attribution" + "state" : "new", + "value" : "Couldn't load the map" } } } }, - "regionMap.kind.attribution.footer" : { - "extractionState" : "manual", + "regionPicker.map.accessibility" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "The simplified polygons the app uses to attribute coordinates today." + "state" : "new", + "value" : "Map for picking your regions" } } } }, - "regionMap.kind.picker" : { - "extractionState" : "manual", + "regionPicker.mode.list" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Geometry" + "state" : "new", + "value" : "List" } } } }, - "regionMap.kind.source" : { - "extractionState" : "manual", + "regionPicker.mode.map" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Source" + "state" : "new", + "value" : "Map" } } } }, - "regionMap.kind.source.footer" : { - "extractionState" : "manual", + "regionPicker.mode.picker" : { + "comment" : "Accessibility label for the map/list mode switch.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity." + "state" : "new", + "value" : "View" } } } }, - "regionMap.legend.header" : { - "extractionState" : "manual", + "regionPicker.search.prompt" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Features" + "state" : "new", + "value" : "Search regions" } } } }, - "regionMap.loadError.title" : { - "extractionState" : "manual", + "regionPicker.selectionCount" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Couldn't load regions" + "state" : "new", + "value" : "%1$lld of %2$lld selected" } } } }, - "regionMap.map.accessibility" : { - "extractionState" : "manual", + "regionPicker.subtitle" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Map of region boundaries" + "state" : "new", + "value" : "Pick up to 5 regions where you spend your time." } } } }, - "regionMap.showAll" : { - "extractionState" : "manual", + "regionPicker.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Show all" + "state" : "new", + "value" : "Your regions" } } } }, - "regionMap.title" : { - "extractionState" : "manual", + "regions.manage.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Region Map" + "state" : "new", + "value" : "Your regions" } } } @@ -1931,222 +2932,167 @@ } } }, - "developer.button.label" : { - "comment" : "Accessibility label for the floating developer button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Developer tools" - } - } - } - }, - "developer.close" : { - "comment" : "Accessibility label for closing the developer panel.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - } - } - }, - "developer.collapse" : { - "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exit full screen" - } - } - } - }, - "developer.expand" : { - "comment" : "Accessibility label for growing the developer panel to full screen.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter full screen" - } - } - } - }, - "developer.footer" : { - "comment" : "Footer for the developer tools list.", + "settings.issueAlerts.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On-device logs and data tools. Debug builds only." + "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." } } } }, - "developer.inspectorLink" : { - "comment" : "Label for the navigation link that opens the SwiftData inspector.", + "settings.issueAlerts.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "SwiftData Inspector" + "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." } } } }, - "developer.inspectorTitle" : { - "comment" : "Navigation title for the SwiftData inspector screen.", + "settings.issueAlerts.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "SwiftData" + "value" : "Issue alerts" } } } }, - "developer.logsLink" : { - "comment" : "Label for the button that opens the logs screen.", + "settings.issueAlerts.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Issue alerts" } } } }, - "developer.logsTitle" : { - "comment" : "Title of the screen that shows the user's logs.", + "settings.location.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience." } } } }, - "developer.regionMapLink" : { - "comment" : "Label for the navigation link that opens the region map.", + "settings.location.grant" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Region map" + "value" : "Grant location access" } } } }, - "developer.title" : { - "comment" : "Navigation title of the developer tools list.", + "settings.location.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Developer" + "value" : "Location" } } } }, - "settings.location.footer" : { + "settings.location.toggle" : { "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." + "value" : "Track in the background" } } } }, - "settings.location.grant" : { + "settings.permissionAlert.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Grant location access" + "value" : "Where needs Always location access to log which region you're in. You can grant it in the Settings app." } } } }, - "settings.location.header" : { + "settings.permissionAlert.notNow" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Location" + "value" : "Not now" } } } }, - "settings.location.toggle" : { + "settings.permissionAlert.openSettings" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Track in the background" + "value" : "Open Settings" } } } }, - "settings.permissionAlert.message" : { + "settings.permissionAlert.title" : { "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." + "value" : "Location access needed" } } } }, - "settings.permissionAlert.notNow" : { - "extractionState" : "manual", + "settings.regions.empty" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Not now" + "state" : "new", + "value" : "None yet" } } } }, - "settings.permissionAlert.openSettings" : { - "extractionState" : "manual", + "settings.regions.row" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Open Settings" + "state" : "new", + "value" : "Primary regions" } } } }, - "settings.permissionAlert.title" : { - "extractionState" : "manual", + "settings.regions.section" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Location access needed" + "state" : "new", + "value" : "Regions" } } } @@ -2361,145 +3307,136 @@ } } }, - "settings.issueAlerts.deniedFooter" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." - } - } - } - }, - "settings.issueAlerts.footer" : { + "settings.summary.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." + "value" : "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings." } } } }, - "settings.issueAlerts.header" : { + "settings.summary.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Issue alerts" + "value" : "Get a morning recap of how many days you've logged in each region so far this year." } } } }, - "settings.issueAlerts.toggle" : { + "settings.summary.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Issue alerts" + "value" : "Daily summary" } } } }, - "settings.summary.deniedFooter" : { + "settings.summary.time" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings." + "value" : "Send at" } } } }, - "settings.summary.footer" : { + "settings.summary.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Get a morning recap of how many days you've logged in each region so far this year." + "value" : "Daily summary" } } } }, - "settings.summary.header" : { + "settings.tabs.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Daily summary" + "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." } } } }, - "settings.summary.time" : { + "settings.tabs.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Send at" + "value" : "Tabs" } } } }, - "settings.summary.toggle" : { + "settings.tabs.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Daily summary" + "value" : "Hide empty tabs" } } } }, - "settings.tabs.footer" : { + "settings.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." + "value" : "Settings" } } } }, - "settings.tabs.header" : { - "extractionState" : "manual", + "settings.year.footer" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Tabs" + "state" : "new", + "value" : "Choose which year your reports, calendar, and logged days cover." } } } }, - "settings.tabs.toggle" : { - "extractionState" : "manual", + "settings.year.header" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Hide empty tabs" + "state" : "new", + "value" : "Report year" } } } }, - "settings.title" : { - "extractionState" : "manual", + "settings.year.label" : { + "comment" : "Label for the year picker in the settings.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Settings" + "state" : "new", + "value" : "Year" } } } From 83c71dfc076dc01aed6f14733e016eba19a1a2ca Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 20:25:34 -0700 Subject: [PATCH 03/14] Commit region picks in one atomic transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review finding #2: the commit looped per-region setPrimaryRegion/removePrimaryRegion calls, each its own store.perform, so N picks fired N changes() pings (attribution rebuild, widget republish, report reload, registry reseed each) and a mid-loop failure left partial state. Replace those single-region WhereServices/WhereStore calls with one batch setPrimaryRegions([PrimaryRegion]) that has replace semantics: upsert each entry (appearance + order) and delete any tracked row not in the list, all inside a single perform. Removals now happen by omission, so the model no longer tracks an initial set to diff against — the selection fully describes the primary set. One transaction, one ping, atomic. Tests updated to the batch API. --- .../Sources/Persistence/SwiftDataStore.swift | 46 +++++++++++-------- .../Sources/Persistence/WhereStore.swift | 24 ++++------ Where/WhereCore/Sources/WhereServices.swift | 22 +++------ .../Tests/PrimaryRegionStoreTests.swift | 44 ++++++++++-------- .../Tests/WidgetDataReaderTests.swift | 8 ++-- .../Regions/PrimaryRegionSelectionModel.swift | 38 ++++++--------- .../PrimaryRegionSelectionModelTests.swift | 22 +++++---- 7 files changed, 99 insertions(+), 105 deletions(-) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 15a5a970..451400a7 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -743,29 +743,35 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { return resolved } - public func setPrimaryRegion( - _ appearance: RegionAppearance?, - id: String, - order: Int?, - ) async throws { + public func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws { let context = mutationContext() - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.regionID == id }, - ) - let existing = try context.fetch(descriptor) - // Collapse any accidental duplicate rows to one (CloudKit can't enforce - // uniqueness), then upsert the row's membership + appearance + order. - let row: SDTrackedRegion - if let first = existing.first { - for extra in existing.dropFirst() { - context.delete(extra) + let desiredIDs = Set(regions.map(\.region.rawValue)) + // Delete every tracked row not in the desired set (and any row with a + // nil id, which we can't resolve) — removals happen by omission. + for row in try context.fetch(FetchDescriptor()) { + if let id = row.regionID, desiredIDs.contains(id) { continue } + context.delete(row) + } + // Upsert each desired region's row (membership + appearance + order), + // collapsing any accidental duplicate rows to one (CloudKit can't + // enforce uniqueness). + for entry in regions { + let id = entry.region.rawValue + let existing = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.regionID == id }, + )) + let row: SDTrackedRegion + if let first = existing.first { + for extra in existing.dropFirst() { + context.delete(extra) + } + row = first + } else { + row = SDTrackedRegion(regionID: id) + context.insert(row) } - row = first - } else { - row = SDTrackedRegion(regionID: id) - context.insert(row) + row.apply(appearance: entry.appearance, order: entry.order) } - row.apply(appearance: appearance, order: order) } private static func logFault(forCorrupt _: Record) { diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 209af1a1..2925316e 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -118,12 +118,13 @@ public protocol WhereStore: Sendable { /// both survive a sync. Must run inside `perform { ... }`. func setTrackedRegion(_ tracked: Bool, id: String) async throws - /// Upsert a primary (tracked) region: ensure a row exists for `id`, then - /// store its `appearance` (clearing the stored look when `nil`) and pick - /// `order`. Adding is the primary-picker's write path — it both tracks the - /// region and records its look — while removal still goes through - /// ``setTrackedRegion(_:id:)`` with `false`. Must run inside `perform { ... }`. - func setPrimaryRegion(_ appearance: RegionAppearance?, id: String, order: Int?) async throws + /// Replace the entire primary set with `regions`: upsert a row per entry + /// (storing its `appearance` — cleared when `nil` — and pick `order`) and + /// delete every tracked row not in `regions`. The picker/customization + /// commit path — the ordered list fully describes the primary (tracked) set, + /// so removals happen by omission rather than a separate call. Must run + /// inside `perform { ... }`. + func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws } extension WhereStore { @@ -153,12 +154,7 @@ extension WhereStore { /// fakes that don't exercise tracked-region persistence inherit the no-op. public func setTrackedRegion(_: Bool, id _: String) async throws {} - /// Default: a no-op. `SwiftDataStore` overrides this to persist the row plus - /// its appearance/order; test fakes that don't exercise persistence inherit - /// the no-op. - public func setPrimaryRegion( - _: RegionAppearance?, - id _: String, - order _: Int?, - ) async throws {} + /// Default: a no-op. `SwiftDataStore` overrides this to replace the persisted + /// rows; test fakes that don't exercise persistence inherit the no-op. + public func setPrimaryRegions(_: [PrimaryRegion]) async throws {} } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 553f7655..ad403ffc 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -264,23 +264,13 @@ public struct WhereServices: Sendable { try await store.primaryRegions() } - /// Upsert a primary (tracked) region's appearance + pick order. The - /// picker/customization commit path; runs the write inside `perform`. - public func setPrimaryRegion( - _ appearance: RegionAppearance?, - id: String, - order: Int?, - ) async throws { + /// Replace the user's primary (tracked) regions with `regions` — the + /// picker/customization commit path. One `perform`, so the whole change + /// (upserts + removals-by-omission) is a single atomic transaction that + /// pings `changes()` once. + public func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws { try await store.perform { - try await store.setPrimaryRegion(appearance, id: id, order: order) - } - } - - /// Remove a primary (tracked) region entirely. Runs the write inside - /// `perform`. - public func removePrimaryRegion(id: String) async throws { - try await store.perform { - try await store.setTrackedRegion(false, id: id) + try await store.setPrimaryRegions(regions) } } diff --git a/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift index 588c73d3..24004ba1 100644 --- a/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift +++ b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift @@ -23,14 +23,22 @@ struct PrimaryRegionStoreTests { #expect(primary.map(\.order) == Array(0 ..< primary.count)) } - @Test func upsertPersistsAppearanceAndOrder() async throws { + private func primary(_ region: Region, _ appearance: RegionAppearance?, _ order: Int) + -> PrimaryRegion + { + PrimaryRegion(region: region, appearance: appearance, order: order) + } + + @Test func replacePersistsAppearanceAndOrder() async throws { let store = try SwiftDataStore.inMemory() let texas = try #require(Region(rawValue: "us-TX")) let caLook = appearance(.orange, "🌴", "sun.max.fill") let txLook = appearance(.red, "🤠", "star.fill") try await store.perform { - try await store.setPrimaryRegion(caLook, id: Region.california.rawValue, order: 0) - try await store.setPrimaryRegion(txLook, id: texas.rawValue, order: 1) + try await store.setPrimaryRegions([ + primary(.california, caLook, 0), + primary(texas, txLook, 1), + ]) } let primary = try await store.primaryRegions() @@ -40,15 +48,15 @@ struct PrimaryRegionStoreTests { #expect(try await store.trackedRegions() == [.california, texas]) } - @Test func upsertOverwritesAnExistingRowLook() async throws { + @Test func replaceOverwritesAnExistingRowLook() async throws { let store = try SwiftDataStore.inMemory() let first = appearance(.orange, "🌴", "sun.max.fill") let second = appearance(.indigo, "🌉", "building.2.fill") try await store.perform { - try await store.setPrimaryRegion(first, id: Region.california.rawValue, order: 0) + try await store.setPrimaryRegions([primary(.california, first, 0)]) } try await store.perform { - try await store.setPrimaryRegion(second, id: Region.california.rawValue, order: 3) + try await store.setPrimaryRegions([primary(.california, second, 3)]) } let primary = try await store.primaryRegions() #expect(primary.count == 1) @@ -56,31 +64,29 @@ struct PrimaryRegionStoreTests { #expect(primary.first?.order == 3) } - @Test func removingAPrimaryRegionUntracksIt() async throws { + @Test func replaceRemovesOmittedRegions() async throws { let store = try SwiftDataStore.inMemory() let texas = try #require(Region(rawValue: "us-TX")) try await store.perform { - try await store.setPrimaryRegion( - appearance(.orange, "🌴", "sun.max.fill"), - id: Region.california.rawValue, - order: 0, - ) - try await store.setPrimaryRegion( - appearance(.red, "🤠", "star.fill"), - id: texas.rawValue, - order: 1, - ) + try await store.setPrimaryRegions([ + primary(.california, appearance(.orange, "🌴", "sun.max.fill"), 0), + primary(texas, appearance(.red, "🤠", "star.fill"), 1), + ]) } + // Re-committing without California removes it by omission. try await store.perform { - try await store.setTrackedRegion(false, id: Region.california.rawValue) + try await store.setPrimaryRegions([ + primary(texas, appearance(.red, "🤠", "star.fill"), 0), + ]) } #expect(try await store.primaryRegions().map(\.region) == [texas]) + #expect(try await store.trackedRegions() == [texas]) } @Test func trackedWithoutAppearanceResolvesToNilLook() async throws { let store = try SwiftDataStore.inMemory() try await store.perform { - try await store.setTrackedRegion(true, id: Region.california.rawValue) + try await store.setPrimaryRegions([primary(.california, nil, 0)]) } let primary = try await store.primaryRegions() #expect(primary.map(\.region) == [.california]) diff --git a/Where/WhereCore/Tests/WidgetDataReaderTests.swift b/Where/WhereCore/Tests/WidgetDataReaderTests.swift index e58dbabb..b27ff46b 100644 --- a/Where/WhereCore/Tests/WidgetDataReaderTests.swift +++ b/Where/WhereCore/Tests/WidgetDataReaderTests.swift @@ -43,9 +43,11 @@ struct WidgetDataReaderTests { let (reader, store) = try Self.makeReader() let caLook = RegionAppearance(color: .orange, emoji: "🌴", symbolName: "sun.max.fill") try await store.perform { - try await store.setPrimaryRegion(caLook, id: Region.california.rawValue, order: 0) - // A tracked region with no picked look contributes no appearance. - try await store.setPrimaryRegion(nil, id: Region.newYork.rawValue, order: 1) + try await store.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: caLook, order: 0), + // A tracked region with no picked look contributes no appearance. + PrimaryRegion(region: .newYork, appearance: nil, order: 1), + ]) } let snapshot = try await reader diff --git a/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift index e6948359..c4eef1af 100644 --- a/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift +++ b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift @@ -27,10 +27,6 @@ public final class PrimaryRegionSelectionModel { /// ``RegionAppearanceCatalog/defaultAppearance(for:)`` until customized. private var drafts: [Region: RegionAppearance] = [:] - /// The regions tracked when the model was created, so the commit can - /// untrack the ones the user removed. - private let initialRegions: Set - /// The US jurisdictions from the catalog, in canonical order — the default /// `available` set for the picker. public static var usRegions: [Region] { @@ -40,19 +36,18 @@ public final class PrimaryRegionSelectionModel { /// A fresh picker with nothing selected (onboarding). public init(available: [Region] = PrimaryRegionSelectionModel.usRegions) { self.available = available - initialRegions = [] } /// A picker seeded from the user's existing primary regions (Settings). /// Selection and drafts are restored so re-opening the editor shows the - /// current picks; regions outside `available` (e.g. legacy non-US defaults) - /// are still counted in `initialRegions` so the commit untracks them. + /// current picks. Regions outside `available` (e.g. legacy non-US defaults) + /// are dropped from the selection — committing then removes them, since the + /// commit replaces the whole primary set with what's selected. public init( existing: [PrimaryRegion], available: [Region] = PrimaryRegionSelectionModel.usRegions, ) { self.available = available - initialRegions = Set(existing.map(\.region)) let offered = Set(available) selectedRegions = existing.map(\.region).filter { offered.contains($0) } var drafts: [Region: RegionAppearance] = [:] @@ -117,21 +112,18 @@ public final class PrimaryRegionSelectionModel { drafts[region] = appearance } - /// Persist the selection: untrack removed regions, then upsert each picked - /// region's appearance and pick order. Runs each write through the services - /// layer (which owns the `perform` transaction). Throws on the first - /// failure so the caller can surface it — no partial success is hidden. - public func commit(using session: WhereSession) async throws { - let selected = Set(selectedRegions) - for region in initialRegions where !selected.contains(region) { - try await session.services.removePrimaryRegion(id: region.rawValue) - } - for (index, region) in selectedRegions.enumerated() { - try await session.services.setPrimaryRegion( - appearance(for: region), - id: region.rawValue, - order: index, - ) + /// The picked regions as ordered ``PrimaryRegion`` values (pick order → + /// `order`), each carrying its current appearance draft. + public var desiredPrimaryRegions: [PrimaryRegion] { + selectedRegions.enumerated().map { index, region in + PrimaryRegion(region: region, appearance: appearance(for: region), order: index) } } + + /// Persist the selection by replacing the primary set with it — one atomic + /// transaction (upserts + removals-by-omission). Throws on failure so the + /// caller can surface it; no partial success is hidden. + public func commit(using session: WhereSession) async throws { + try await session.services.setPrimaryRegions(desiredPrimaryRegions) + } } diff --git a/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift index 733dd827..a9d50da6 100644 --- a/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift +++ b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift @@ -92,16 +92,18 @@ struct PrimaryRegionSelectionModelTests { @Test func commitUntracksRemovedRegions() async throws { let session = PreviewSupport.loadedSession() // Seed the store with two picks, then commit an edit that drops one. - try await session.services.setPrimaryRegion( - RegionAppearanceCatalog.defaultAppearance(for: .california), - id: Region.california.rawValue, - order: 0, - ) - try await session.services.setPrimaryRegion( - RegionAppearanceCatalog.defaultAppearance(for: .newYork), - id: Region.newYork.rawValue, - order: 1, - ) + try await session.services.setPrimaryRegions([ + PrimaryRegion( + region: .california, + appearance: RegionAppearanceCatalog.defaultAppearance(for: .california), + order: 0, + ), + PrimaryRegion( + region: .newYork, + appearance: RegionAppearanceCatalog.defaultAppearance(for: .newYork), + order: 1, + ), + ]) let existing = try await session.services.primaryRegions() let model = PrimaryRegionSelectionModel(existing: existing) From e82e1ce81f63a398a9700f6bb4af50ae28ce03d5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 20:31:51 -0700 Subject: [PATCH 04/14] Document US-only convergence when editing the default region set Address code-review finding #3. The app is US-only now (the legacy Canada/EU regions ship low-resolution polygons and aren't pickable), so seeding the editor from the default set and saving intentionally drops the non-US regions. Spell that out in PrimaryRegionSelectionModel's init(existing:) doc and pin it with a test: loading the default set into the model selects only CA/NY, and committing narrows the tracked set to those. --- .../Regions/PrimaryRegionSelectionModel.swift | 11 ++++++++--- .../Tests/PrimaryRegionSelectionModelTests.swift | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift index c4eef1af..a2e5d49a 100644 --- a/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift +++ b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift @@ -40,9 +40,14 @@ public final class PrimaryRegionSelectionModel { /// A picker seeded from the user's existing primary regions (Settings). /// Selection and drafts are restored so re-opening the editor shows the - /// current picks. Regions outside `available` (e.g. legacy non-US defaults) - /// are dropped from the selection — committing then removes them, since the - /// commit replaces the whole primary set with what's selected. + /// current picks. + /// + /// Regions outside `available` are dropped from the selection, so committing + /// removes them (the commit replaces the whole primary set with what's + /// selected). This is intentional: the app is US-only now, and the only + /// non-US regions a user can have are the legacy default set (Canada / the + /// EU, whose low-resolution polygons we no longer ship as pickable) — so a + /// fresh install that opens the editor and saves converges to the US picks. public init( existing: [PrimaryRegion], available: [Region] = PrimaryRegionSelectionModel.usRegions, diff --git a/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift index a9d50da6..cf78f41c 100644 --- a/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift +++ b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift @@ -89,6 +89,22 @@ struct PrimaryRegionSelectionModelTests { #expect(try await session.services.trackedRegions() == [.california]) } + @Test func editingTheDefaultSetConvergesToUSOnly() async throws { + // A fresh install has no stored rows, so `primaryRegions()` returns the + // legacy default set (CA / NY / Canada / EU). Opening the editor drops + // the non-US regions from the selection, and saving unchanged removes + // them — the app is US-only now (documented behavior, not a bug). + let session = PreviewSupport.loadedSession() + let existing = try await session.services.primaryRegions() + #expect(Set(existing.map(\.region)) == SwiftDataStore.defaultTrackedRegions) + + let model = PrimaryRegionSelectionModel(existing: existing) + #expect(Set(model.selectedRegions) == [.california, .newYork]) + + try await model.commit(using: session) + #expect(try await session.services.trackedRegions() == [.california, .newYork]) + } + @Test func commitUntracksRemovedRegions() async throws { let session = PreviewSupport.loadedSession() // Seed the store with two picks, then commit an edit that drops one. From 14621528bb6205805db15671c801df8d6212bd62 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 20:56:26 -0700 Subject: [PATCH 05/14] Resolve region styles via the view environment, not a global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review finding #4: RegionStyle read from a process-wide RegionStyleRegistry.shared singleton — fine in production but a latent test-isolation hazard in the shared multi-bundle host, and it made styles update lazily rather than reactively. Replace it with a RegionStyleResolver injected through the environment (\.regionStyles), seeded by whereBroadwayRoot(regionStyles:): - the app passes WhereSession's live resolver (updated on launch + store changes), so a Settings edit / remote import now restyles the UI reactively; - the widget process builds one from WidgetSnapshot.appearances; - App Intents snippets build one from their services. The default empty resolver yields fallback looks (previews, RegionViewer). Views now read @Environment(\.regionStyles) and call regionStyles.style(for:); the global region.style / RegionStyle.style(for:) and the registry are gone. Renamed the file/test to RegionStyleResolver and updated docs. --- .../Sources/Widgets/WidgetDataReader.swift | 8 +-- .../Sources/DaysInRegionSnippetIntent.swift | 9 +++- Where/WhereUI/AGENTS.md | 17 +++--- Where/WhereUI/README.md | 12 ++--- .../Sources/Developer/RegionMapView.swift | 3 +- .../Sources/Intents/IntentSnippets.swift | 16 +++--- Where/WhereUI/Sources/Model/RegionStyle.swift | 20 +------ .../Sources/Model/RegionStyleRegistry.swift | 45 ---------------- .../Sources/Model/RegionStyleResolver.swift | 51 ++++++++++++++++++ .../WhereUI/Sources/Model/WhereSession.swift | 26 +++++---- .../Sources/Primary/CalendarView.swift | 6 ++- .../Primary/PresenceTimelineView.swift | 3 +- .../Sources/Primary/RegionSummaryCard.swift | 9 ++-- .../Sources/Regions/RegionPickerView.swift | 11 ++-- Where/WhereUI/Sources/RootView.swift | 7 ++- .../Sources/Secondary/RegionDaysView.swift | 11 ++-- .../Sources/Shared/RegionToggleRow.swift | 4 +- .../Sources/Shared/WhereStylesheet.swift | 11 +++- .../Sources/Widgets/TodayAccessoryViews.swift | 13 +++-- .../Sources/Widgets/TodayWidgetView.swift | 13 +++-- .../YearTotalsRectangularAccessoryView.swift | 3 +- .../Widgets/YearTotalsWidgetView.swift | 5 +- .../Tests/RegionStyleRegistryTests.swift | 52 ------------------ .../Tests/RegionStyleResolverTests.swift | 54 +++++++++++++++++++ Where/WhereWidgets/Sources/TodayWidget.swift | 7 ++- .../Sources/WhereWidgetProvider.swift | 6 --- .../Sources/YearTotalsWidget.swift | 7 ++- 27 files changed, 237 insertions(+), 192 deletions(-) delete mode 100644 Where/WhereUI/Sources/Model/RegionStyleRegistry.swift create mode 100644 Where/WhereUI/Sources/Model/RegionStyleResolver.swift delete mode 100644 Where/WhereUI/Tests/RegionStyleRegistryTests.swift create mode 100644 Where/WhereUI/Tests/RegionStyleResolverTests.swift diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index d7f40740..6879639f 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -21,10 +21,10 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable { public let totals: [Region: Int] /// The user's picked appearances for their primary regions, carried across /// the App Group so the widget process can render each region's chosen - /// color/emoji/icon (it has no store access and no `WhereSession` to seed - /// `RegionStyleRegistry`). Empty for regions the user hasn't customized (and - /// for snapshots written before this field existed) — those fall back to the - /// default look. + /// color/emoji/icon (it has no store access and no `WhereSession`, so it + /// seeds its `RegionStyleResolver` from this). Empty for regions the user + /// hasn't customized (and for snapshots written before this field existed) — + /// those fall back to the default look. public let appearances: [Region: RegionAppearance] public init( diff --git a/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift b/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift index f898d918..310ec064 100644 --- a/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift +++ b/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift @@ -35,6 +35,9 @@ public struct DaysInRegionSnippetIntent: SnippetIntent { year: resolvedYear, dayCount: count, ) + // Seed the region look from the user's picks so the snippet renders the + // chosen color/emoji/icon (the intent process has no app view root). + let regionStyles = try await RegionStyleResolver(primaryRegions: services.primaryRegions()) // "Log today here" logs into the *current* year, so it can only change // this card's count when the card is showing the current year. Omit it // otherwise, rather than offer a button that appears to do nothing. @@ -43,6 +46,7 @@ public struct DaysInRegionSnippetIntent: SnippetIntent { snapshot: snapshot, region: region, canLogToday: isCurrentYear(resolvedYear), + regionStyles: regionStyles, ), ) } @@ -56,10 +60,11 @@ struct DaysInRegionInteractiveSnippet: View { let snapshot: DaysInRegionSnapshot let region: RegionEntity let canLogToday: Bool + let regionStyles: RegionStyleResolver var body: some View { content - .whereBroadwayRoot() + .whereBroadwayRoot(regionStyles: regionStyles) } @ViewBuilder private var content: some View { @@ -76,7 +81,7 @@ struct DaysInRegionInteractiveSnippet: View { .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) - .tint(snapshot.region.style.tint) + .tint(regionStyles.style(for: snapshot.region).tint) } } else { DaysInRegionSnippetView(snapshot: snapshot) diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 1a64af9e..d8325a42 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -68,12 +68,17 @@ owned by a single component on `Palette`, the few bespoke display faces on `RegionStyle` (not the stylesheet); adaptive system roles (`.secondary`) and `.accentColor` stay inline. -`RegionStyle` is **data-driven**: `region.style` resolves the user's picked -`RegionAppearance` from the process-wide `RegionStyleRegistry` (seeded from the -store by `WhereSession` on launch + `changes()`, and from the `WidgetSnapshot` -in the widget process), falling back to `RegionAppearanceCatalog.defaultAppearance(for:)`. -The catalog also owns the selectable color/emoji/symbol option lists the picker -shows. Don't reintroduce a hardcoded per-region look in a view. +`RegionStyle` is **data-driven** and resolved through the environment: views +read `@Environment(\.regionStyles)` (a `RegionStyleResolver`) and call +`regionStyles.style(for: region)` — there is no global `region.style`. The +resolver is seeded by `whereBroadwayRoot(regionStyles:)`: the app passes +`WhereSession`'s live resolver (updated on launch + `changes()`), the widget +process one built from its `WidgetSnapshot`, and App Intents snippets one from +their services; the default empty resolver yields the fallback looks +(`RegionAppearanceCatalog.defaultAppearance(for:)`) for previews and the +region-map viewer. The catalog also owns the selectable color/emoji/symbol +option lists the picker shows. Don't reintroduce a global accessor or a +hardcoded per-region look in a view. ### Trait-aware tokens diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index b5dea15f..e06e1c5f 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -51,12 +51,12 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's with: `TodayWidgetView`, `YearTotalsWidgetView`, and the accessory family (`TodayInlineAccessoryView`, `TodayCircularAccessoryView`, `YearTotalsRectangularAccessoryView`). Each takes a `WidgetSnapshot`. -- **`RegionStyle`** — a region's symbol, emoji, and tint (read as - `region.style`); the per-region look shared across cards, calendar dots, and - timelines. It resolves the user's picked appearance from `RegionStyleRegistry` - (seeded from the store by `WhereSession`, and from the `WidgetSnapshot` in the - widget process), falling back to a deterministic default from - `RegionAppearanceCatalog`. +- **`RegionStyle` / `RegionStyleResolver`** — a region's symbol, emoji, and + tint, shared across cards, calendar dots, and timelines. Views resolve it from + `@Environment(\.regionStyles)` (`regionStyles.style(for: region)`), seeded by + `whereBroadwayRoot(regionStyles:)` — from `WhereSession`'s live resolver in the + app, the `WidgetSnapshot` in the widget process, and services in App Intents — + falling back to a deterministic default from `RegionAppearanceCatalog`. - **`whereBroadwayRoot()`** — seeds the Broadway design-system context so descendants resolve the `WhereStylesheet` tokens (see [Design system](#design-system)). Applied by `RootView` and by each widget. diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index 1ce52a44..e1e13f23 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -25,6 +25,7 @@ public struct RegionMapView: View { @State private var cameraPosition: MapCameraPosition = .automatic @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles /// Public so the standalone `RegionViewer` app (a separate module) can /// present the same screen as the in-app developer overlay entry. @@ -228,7 +229,7 @@ public struct RegionMapView: View { /// gets a stable color derived from its title so the same feature is /// always the same hue across launches. private func color(forTitle title: String, region: Region?) -> Color { - if let region { return region.style.tint } + if let region { return regionStyles.style(for: region).tint } return Self.palette[Self.paletteIndex(for: title)] } diff --git a/Where/WhereUI/Sources/Intents/IntentSnippets.swift b/Where/WhereUI/Sources/Intents/IntentSnippets.swift index 81e5efc7..76ffc3d1 100644 --- a/Where/WhereUI/Sources/Intents/IntentSnippets.swift +++ b/Where/WhereUI/Sources/Intents/IntentSnippets.swift @@ -29,14 +29,16 @@ public struct DaysInRegionSnippetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var region: Region { snapshot.region } public var body: some View { - HStack(spacing: stylesheet.spacing.medium) { - Text(region.style.emoji) + let style = regionStyles.style(for: region) + return HStack(spacing: stylesheet.spacing.medium) { + Text(style.emoji) // Semantic Dynamic Type face, matching TodayWidgetView's hero // emoji — no hardcoded point size. .font(.largeTitle) @@ -44,7 +46,7 @@ public struct DaysInRegionSnippetView: View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { Text(snapshot.dayCount, format: .number) .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) .contentTransition(.numericText()) Text(caption) .font(.subheadline) @@ -103,6 +105,7 @@ public struct RegionsSnippetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles public var body: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.small) { @@ -124,12 +127,13 @@ public struct RegionsSnippetView: View { private var chips: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { ForEach(regions, id: \.self) { region in + let style = regionStyles.style(for: region) HStack(spacing: stylesheet.spacing.small) { - Text(region.style.emoji) + Text(style.emoji) .accessibilityHidden(true) Text(region.localizedName) .font(.headline) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) } } } @@ -185,7 +189,7 @@ extension RegionsSnippetView { ) { Button("Log today here") {} .buttonStyle(.borderedProminent) - .tint(Region.california.style.tint) + .tint(RegionStyle.fallbackStyle(for: .california).tint) .frame(maxWidth: .infinity) } .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Model/RegionStyle.swift b/Where/WhereUI/Sources/Model/RegionStyle.swift index 781eb0cc..c48ec088 100644 --- a/Where/WhereUI/Sources/Model/RegionStyle.swift +++ b/Where/WhereUI/Sources/Model/RegionStyle.swift @@ -30,30 +30,12 @@ public struct RegionStyle: Sendable { ) } - /// The region's look: the user's picked appearance if they've customized it - /// (via `RegionStyleRegistry`, seeded from the store), otherwise a stable - /// fallback — a small table of hand-tuned looks for the regions the app - /// shipped with, and an id-derived default for everything else. - public static func style(for region: Region) -> RegionStyle { - if let appearance = RegionStyleRegistry.shared.appearance(for: region) { - return RegionStyle(appearance) - } - return fallbackStyle(for: region) - } - /// The look for a region with no user-picked appearance: the region's /// default ``RegionAppearance`` (a hand-tuned look for the regions the app /// shipped with, an id-derived default for everything else). Sharing /// `RegionAppearanceCatalog.defaultAppearance(for:)` keeps the fallback and /// the customization pre-fill in lockstep — a picked appearance always wins. - static func fallbackStyle(for region: Region) -> RegionStyle { + public static func fallbackStyle(for region: Region) -> RegionStyle { RegionStyle(RegionAppearanceCatalog.defaultAppearance(for: region)) } } - -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/RegionStyleRegistry.swift b/Where/WhereUI/Sources/Model/RegionStyleRegistry.swift deleted file mode 100644 index 9b515d49..00000000 --- a/Where/WhereUI/Sources/Model/RegionStyleRegistry.swift +++ /dev/null @@ -1,45 +0,0 @@ -import RegionKit -import Synchronization -import WhereCore - -/// Process-wide source of the user's picked region appearances that backs -/// `RegionStyle.style(for:)`. It exists so the ergonomic, synchronous -/// `region.style` accessor — read from view bodies, widget views, and App -/// Intents snippets alike — can resolve *persisted* looks without every call -/// site threading a lookup or an environment value. -/// -/// Written from a single place per process: the app seeds it from the store via -/// `WhereSession` (at launch and on every store change); the widget process -/// seeds it from the `WidgetSnapshot` it renders. Reads are lock-guarded so the -/// off-main-actor callers (snippets) are safe. A region with no entry falls back -/// to the deterministic default look in `RegionStyle`. -public final class RegionStyleRegistry: Sendable { - public static let shared = RegionStyleRegistry() - - private let storage = Mutex<[Region: RegionAppearance]>([:]) - - public init() {} - - /// The picked appearance for `region`, or `nil` when the user hasn't - /// customized it (so `RegionStyle` uses its fallback default). - public func appearance(for region: Region) -> RegionAppearance? { - storage.withLock { $0[region] } - } - - /// Replace the whole map — the primary-region set is small and always read - /// as a whole, so a wholesale swap keeps the registry a faithful mirror of - /// the store (a removed region loses its entry, not just changed ones). - public func replaceAll(_ appearances: [Region: RegionAppearance]) { - storage.withLock { $0 = appearances } - } - - /// Build the map from ordered `PrimaryRegion`s, keeping only the ones that - /// carry a resolved appearance. - public func replaceAll(from primaryRegions: [PrimaryRegion]) { - var map: [Region: RegionAppearance] = [:] - for entry in primaryRegions { - if let appearance = entry.appearance { map[entry.region] = appearance } - } - replaceAll(map) - } -} diff --git a/Where/WhereUI/Sources/Model/RegionStyleResolver.swift b/Where/WhereUI/Sources/Model/RegionStyleResolver.swift new file mode 100644 index 00000000..c0993b1f --- /dev/null +++ b/Where/WhereUI/Sources/Model/RegionStyleResolver.swift @@ -0,0 +1,51 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Resolves a `Region` to its `RegionStyle`, using the user's picked +/// ``RegionAppearance``s when present and falling back to the deterministic +/// default look otherwise. +/// +/// Injected into the view environment (`\.regionStyles`) by `whereBroadwayRoot`, +/// so every surface resolves the *same* styles from a single seeded value rather +/// than a global: the app seeds it from `WhereSession` (live on store changes), +/// the widget process from its `WidgetSnapshot`, and App Intents snippets from +/// their services. An empty resolver (the environment default) yields the +/// fallback looks — correct for previews, the standalone region-map viewer, and +/// any surface that hasn't been seeded. +public struct RegionStyleResolver: Sendable, Equatable { + private let appearances: [Region: RegionAppearance] + + /// The empty resolver: every region resolves to its fallback look. + public static let `default` = RegionStyleResolver(appearances: [:]) + + public init(appearances: [Region: RegionAppearance]) { + self.appearances = appearances + } + + /// Build from ordered `PrimaryRegion`s, keeping only the ones that carry a + /// resolved appearance. + public init(primaryRegions: [PrimaryRegion]) { + var map: [Region: RegionAppearance] = [:] + for entry in primaryRegions { + if let appearance = entry.appearance { map[entry.region] = appearance } + } + self.init(appearances: map) + } + + /// The region's look: its picked appearance if customized, else the stable + /// fallback (a hand-tuned look for the shipped regions, an id-derived default + /// otherwise). + public func style(for region: Region) -> RegionStyle { + if let appearance = appearances[region] { + return RegionStyle(appearance) + } + return RegionStyle.fallbackStyle(for: region) + } +} + +extension EnvironmentValues { + /// The active region-style resolver, seeded by `whereBroadwayRoot`. Defaults + /// to the empty resolver (fallback looks) when nothing seeded it. + @Entry public var regionStyles: RegionStyleResolver = .default +} diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 701acbfc..75e01f3b 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -77,11 +77,17 @@ public final class WhereSession { /// access to race. @ObservationIgnored private nonisolated(unsafe) var authorizationTask: Task? - /// Observes `dataChangeUpdates()` to keep `RegionStyleRegistry` in sync with - /// the store's picked region appearances. Same `nonisolated(unsafe)` rationale - /// as `authorizationTask` — only touched on the main actor except `deinit`. + /// Observes `dataChangeUpdates()` to keep ``regionStyles`` in sync with the + /// store's picked region appearances. Same `nonisolated(unsafe)` rationale as + /// `authorizationTask` — only touched on the main actor except `deinit`. @ObservationIgnored private nonisolated(unsafe) var regionStyleTask: Task? + /// The user's picked region looks, resolved for the view environment (seeded + /// into `whereBroadwayRoot(regionStyles:)` by `RootView`). Loaded at launch + /// and kept live on every store change, so a Settings edit or a synced pick + /// from another device restyles the UI without a relaunch. + public private(set) var regionStyles: RegionStyleResolver = .default + private static let logger = WhereLog.channel(.session) /// The authorization the degradation warning was last evaluated against. @@ -241,15 +247,15 @@ public final class WhereSession { } } - /// Load the user's picked region appearances into `RegionStyleRegistry` so - /// `region.style` reflects them everywhere the UI renders a region. A launch - /// step (see `WhereLaunch.sequence`); also re-run on every store change via - /// `observeRegionStyleChanges()`. On failure the registry keeps its last good - /// map (honest degraded state) and the failure is logged. + /// Load the user's picked region appearances into ``regionStyles`` so the + /// UI resolves them everywhere it renders a region. A launch step (see + /// `WhereLaunch.sequence`); also re-run on every store change via + /// `observeRegionStyleChanges()`. On failure it keeps the last good resolver + /// (honest degraded state) and logs. func seedRegionStyles() async { do { let primary = try await services.primaryRegions() - RegionStyleRegistry.shared.replaceAll(from: primary) + regionStyles = RegionStyleResolver(primaryRegions: primary) } catch { Self.logger.warning("Failed to load region appearances for styling") } @@ -257,7 +263,7 @@ public final class WhereSession { /// Subscribe to store changes (local commits + remote CloudKit imports) so a /// customized region's look stays live — a Settings edit or a synced pick on - /// another device reseeds `RegionStyleRegistry`. Idempotent. + /// another device reloads ``regionStyles``. Idempotent. func observeRegionStyleChanges() { guard regionStyleTask == nil else { return } let services = services diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index 083c88f8..789b7419 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -234,6 +234,7 @@ private struct MonthFooter: View { var focusedRegion: Region? @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var calendar: WhereStylesheet.CalendarStyle { stylesheet.calendar @@ -252,7 +253,7 @@ private struct MonthFooter: View { let isFocused = tally.region == focusedRegion return HStack(spacing: calendar.month.footerRowSpacing) { Circle() - .fill(tally.region.style.tint) + .fill(regionStyles.style(for: tally.region).tint) .frame( width: calendar.dotSize, height: calendar.dotSize, @@ -282,6 +283,7 @@ private struct DayCell: View { let day: CalendarDayCell @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var calendar: WhereStylesheet.CalendarStyle { stylesheet.calendar @@ -323,7 +325,7 @@ private struct DayCell: View { HStack(spacing: calendar.dayContentSpacing) { ForEach(day.regions, id: \.self) { region in Circle() - .fill(region.style.tint) + .fill(regionStyles.style(for: region).tint) .frame( width: calendar.dotSize, height: calendar.dotSize, diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 000271dc..7a615fe3 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -74,13 +74,14 @@ private struct StintRow: View { let stint: RegionStint @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var timeline: WhereStylesheet.TimelineStyle { stylesheet.timeline } private var style: RegionStyle { - stint.region.style + regionStyles.style(for: stint.region) } var body: some View { diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 2bbb1429..f8b79121 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -37,13 +37,14 @@ struct RegionSummaryCard: View { /// static sheen. var tilt: TiltProvider? - /// An explicit style to render instead of the region's persisted - /// `region.style`. The region-customization screen passes the in-progress + /// An explicit style to render instead of resolving the region's look from + /// `\.regionStyles`. The region-customization screen passes the in-progress /// draft appearance so the card previews a pick before it's saved; every - /// other caller leaves it `nil` and gets the stored look. + /// other caller leaves it `nil` and gets the resolved look. var styleOverride: RegionStyle? @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles /// The resolved spec for this card's variant, read once so the rest of the /// view is a straight-line render with no `compact` branching. @@ -52,7 +53,7 @@ struct RegionSummaryCard: View { } private var style: RegionStyle { - styleOverride ?? regionDays.region.style + styleOverride ?? regionStyles.style(for: regionDays.region) } private var cardShape: RoundedRectangle { diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift index b71d9371..9d9532cf 100644 --- a/Where/WhereUI/Sources/Regions/RegionPickerView.swift +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -24,6 +24,7 @@ struct RegionPickerView: View { @State private var mapData: Result? @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private static let logger = WhereLog.channel(.regionAttribution) @@ -85,7 +86,7 @@ struct RegionPickerView: View { Map(initialPosition: .region(unitedStatesRegion)) { ForEach(data.outlines) { outline in let selected = outline.region.map(model.isSelected) ?? false - let tint = outline.region?.style.tint ?? .gray + let tint = outline.region.map { regionStyles.style(for: $0).tint } ?? .gray MapPolygon(coordinates: outline.coordinates.clLocationCoordinates) .foregroundStyle(tint.opacity( selected ? style.selectedFillOpacity : style.unselectedFillOpacity, @@ -213,10 +214,12 @@ private struct RegionPickerRow: View { let isSelected: Bool @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles var body: some View { - HStack(spacing: stylesheet.spacing.medium) { - Text(region.style.emoji) + let style = regionStyles.style(for: region) + return HStack(spacing: stylesheet.spacing.medium) { + Text(style.emoji) .accessibilityHidden(true) Text(region.localizedName) .foregroundStyle(.primary) @@ -224,7 +227,7 @@ private struct RegionPickerRow: View { if isSelected { Image(systemName: "checkmark") .fontWeight(.semibold) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) } } .contentShape(.rect) diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index a9276d46..938452af 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -111,8 +111,11 @@ public struct RootView: View { } // Seed the Broadway context at the app root so descendants resolve // `WhereStylesheet` (via `@Environment(\.stylesheet)`) against the live - // system traits and the app's themes. - .whereBroadwayRoot() + // system traits and the app's themes, plus the session's live region + // styles (`\.regionStyles`) so cards/calendar/onboarding render the + // user's picked looks. `.default` before the session exists (splash) and + // reactive after, since reading `session.regionStyles` tracks it. + .whereBroadwayRoot(regionStyles: model.session?.regionStyles ?? .default) } /// How the launch splash gives way to the app once the runner is `.ready`: diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index b0a46646..1eda2cbd 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -19,6 +19,7 @@ struct RegionDaysView: View { @State private var coordinatesByDay: [CalendarDay: [Coordinate]] = [:] @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var regionMap: WhereStylesheet.RegionMapStyle { stylesheet.regionMap @@ -71,19 +72,19 @@ struct RegionDaysView: View { } private var map: some View { - Map(initialPosition: .automatic) { + let tint = regionStyles.style(for: region).tint + return Map(initialPosition: .automatic) { ForEach(pins) { pin in if let radius = drawnUncertaintyRadius(for: pin) { MapCircle(center: pin.coordinate, radius: radius) - .foregroundStyle(region.style.tint - .opacity(regionMap.uncertaintyFillOpacity)) + .foregroundStyle(tint.opacity(regionMap.uncertaintyFillOpacity)) .stroke( - region.style.tint.opacity(regionMap.uncertaintyStrokeOpacity), + tint.opacity(regionMap.uncertaintyStrokeOpacity), lineWidth: regionMap.uncertaintyStrokeWidth, ) } Marker("", coordinate: pin.coordinate) - .tint(region.style.tint) + .tint(tint) } } .mapStyle(.standard(pointsOfInterest: .excludingAll)) diff --git a/Where/WhereUI/Sources/Shared/RegionToggleRow.swift b/Where/WhereUI/Sources/Shared/RegionToggleRow.swift index bfaeb5a0..4823bba4 100644 --- a/Where/WhereUI/Sources/Shared/RegionToggleRow.swift +++ b/Where/WhereUI/Sources/Shared/RegionToggleRow.swift @@ -5,12 +5,14 @@ import WhereCore struct RegionToggleRow: View { @Bindable var item: RegionToggleItem + @Environment(\.regionStyles) private var regionStyles + var body: some View { Toggle(isOn: $item.isOn) { Label { Text(item.region.localizedName) } icon: { - Text(item.region.style.emoji) + Text(regionStyles.style(for: item.region).emoji) } } } diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 2cacd58d..56f1fefa 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -704,8 +704,17 @@ extension View { /// directly — it already gets it through `WhereUI` (a dynamic framework), and /// a second copy would split Broadway's type-keyed environment metadata (see /// the root `AGENTS.md` "Targets" note). - public func whereBroadwayRoot() -> some View { + /// + /// Also seeds `\.regionStyles` so descendants resolve per-region looks + /// (`region` cards, calendar dots, widgets, snippets) from one place. The app + /// passes `WhereSession`'s live resolver, the widget process one built from + /// its `WidgetSnapshot`, and intents one from their services; the default + /// empty resolver yields fallback looks (previews, the region-map viewer). + public func whereBroadwayRoot( + regionStyles: RegionStyleResolver = .default, + ) -> some View { broadwayRoot(themes: WhereThemes.current) + .environment(\.regionStyles, regionStyles) } } diff --git a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift index 70793e63..5cdcb2d8 100644 --- a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift +++ b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift @@ -13,6 +13,8 @@ public struct TodayInlineAccessoryView: View { self.snapshot = snapshot } + @Environment(\.regionStyles) private var regionStyles + private var regions: [Region] { snapshot.orderedDayRegions } @@ -21,7 +23,7 @@ public struct TodayInlineAccessoryView: View { if let first = regions.first { Label( regions.map(\.localizedName).joined(separator: " · "), - systemImage: first.style.symbolName, + systemImage: regionStyles.style(for: first).symbolName, ) } else { Label(Strings.widgetTodayEmpty, systemImage: "location.slash") @@ -39,6 +41,8 @@ public struct TodayCircularAccessoryView: View { self.snapshot = snapshot } + @Environment(\.regionStyles) private var regionStyles + private var regions: [Region] { snapshot.orderedDayRegions } @@ -47,8 +51,11 @@ public struct TodayCircularAccessoryView: View { ZStack { AccessoryWidgetBackground() VStack(spacing: 0) { - Image(systemName: regions.first?.style.symbolName ?? "location.slash") - .font(.title3) + Image( + systemName: regions.first + .map { regionStyles.style(for: $0).symbolName } ?? "location.slash", + ) + .font(.title3) if regions.count > 1 { Text(verbatim: "+\(regions.count - 1)") .font(.caption2.weight(.semibold)) diff --git a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift index 73dd426b..69861269 100644 --- a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift @@ -16,6 +16,7 @@ public struct TodayWidgetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var regions: [Region] { snapshot.orderedDayRegions @@ -54,15 +55,16 @@ public struct TodayWidgetView: View { /// The common case — one region so far today — gets the full passport /// treatment: big emoji, serif uppercase name in the region's tint. private func heroRegion(_ region: Region) -> some View { - VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { - Text(region.style.emoji) + let style = regionStyles.style(for: region) + return VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { + Text(style.emoji) .font(.largeTitle) .accessibilityHidden(true) Text(region.localizedName) .font(stylesheet.typography.widgetHeroRegion) .textCase(.uppercase) .tracking(1) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) .lineLimit(2) .minimumScaleFactor(0.6) } @@ -72,13 +74,14 @@ public struct TodayWidgetView: View { private var regionList: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { ForEach(regions, id: \.self) { region in + let style = regionStyles.style(for: region) HStack(spacing: stylesheet.spacing.small) { - Text(region.style.emoji) + Text(style.emoji) .font(.caption) .accessibilityHidden(true) Text(region.localizedName) .font(.caption.weight(.semibold)) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) .lineLimit(1) .minimumScaleFactor(0.7) } diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift index 8af74e24..0aede5cf 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift @@ -15,6 +15,7 @@ public struct YearTotalsRectangularAccessoryView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var ranked: [RegionDays] { snapshot.rankedTotals(maxRows: Self.maxRows) @@ -28,7 +29,7 @@ public struct YearTotalsRectangularAccessoryView: View { VStack(alignment: .leading, spacing: 0) { ForEach(ranked) { entry in HStack(spacing: stylesheet.spacing.xSmall) { - Image(systemName: entry.region.style.symbolName) + Image(systemName: regionStyles.style(for: entry.region).symbolName) .font(.caption2) .accessibilityHidden(true) Text(entry.region.localizedName) diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift index f447c6df..e661b8aa 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift @@ -17,6 +17,7 @@ public struct YearTotalsWidgetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var ranked: [RegionDays] { snapshot.rankedTotals(maxRows: maxRows) @@ -47,7 +48,7 @@ public struct YearTotalsWidgetView: View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { ForEach(ranked) { entry in HStack(spacing: stylesheet.spacing.small) { - Text(entry.region.style.emoji) + Text(regionStyles.style(for: entry.region).emoji) .font(.caption) .accessibilityHidden(true) Text(entry.region.localizedName) @@ -58,7 +59,7 @@ public struct YearTotalsWidgetView: View { Text(entry.days, format: .number) .font(stylesheet.typography.widgetTotalNumber) .monospacedDigit() - .foregroundStyle(entry.region.style.tint) + .foregroundStyle(regionStyles.style(for: entry.region).tint) } .accessibilityElement(children: .combine) .accessibilityLabel( diff --git a/Where/WhereUI/Tests/RegionStyleRegistryTests.swift b/Where/WhereUI/Tests/RegionStyleRegistryTests.swift deleted file mode 100644 index 11b06bf6..00000000 --- a/Where/WhereUI/Tests/RegionStyleRegistryTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -import RegionKit -import Testing -@testable import WhereCore -@testable import WhereUI - -/// `RegionStyle.style(for:)` resolves a picked appearance through -/// `RegionStyleRegistry`, falling back to the deterministic default look. -struct RegionStyleRegistryTests { - @Test func pickedAppearanceWinsOverFallback() { - let registry = RegionStyleRegistry() - let look = RegionAppearance(color: .pink, emoji: "🎸", symbolName: "star.fill") - registry.replaceAll([.california: look]) - - #expect(registry.appearance(for: .california) == look) - // A region with no entry resolves to nil (the caller uses its fallback). - #expect(registry.appearance(for: .newYork) == nil) - - let style = RegionStyle(look) - #expect(style.emoji == "🎸") - #expect(style.symbolName == "star.fill") - } - - @Test func replaceAllFromPrimaryRegionsKeepsOnlyResolvedLooks() { - let registry = RegionStyleRegistry() - let look = RegionAppearance(color: .teal, emoji: "🌊", symbolName: "water.waves") - registry.replaceAll(from: [ - PrimaryRegion(region: .california, appearance: look, order: 0), - PrimaryRegion(region: .newYork, appearance: nil, order: 1), - ]) - - #expect(registry.appearance(for: .california) == look) - #expect(registry.appearance(for: .newYork) == nil) - } - - @Test func replaceAllRemovesStalePicks() { - let registry = RegionStyleRegistry() - let look = RegionAppearance(color: .red, emoji: "🍁", symbolName: "leaf.fill") - registry.replaceAll([.canada: look]) - // A wholesale swap drops a region that's no longer primary. - registry.replaceAll([:]) - #expect(registry.appearance(for: .canada) == nil) - } - - @Test func defaultAppearanceMatchesSharedCatalog() { - // The customization pre-fill and the RegionStyle fallback share one - // source, so a region with no pick renders its catalog default. - let expected = RegionAppearanceCatalog.defaultAppearance(for: .california) - let fallback = RegionStyle.fallbackStyle(for: .california) - #expect(fallback.emoji == expected.emoji) - #expect(fallback.symbolName == expected.symbolName) - } -} diff --git a/Where/WhereUI/Tests/RegionStyleResolverTests.swift b/Where/WhereUI/Tests/RegionStyleResolverTests.swift new file mode 100644 index 00000000..de6519f6 --- /dev/null +++ b/Where/WhereUI/Tests/RegionStyleResolverTests.swift @@ -0,0 +1,54 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// `RegionStyleResolver.style(for:)` resolves a picked appearance, falling back +/// to the deterministic default look for regions the user hasn't customized. +struct RegionStyleResolverTests { + @Test func pickedAppearanceWinsOverFallback() { + let look = RegionAppearance(color: .pink, emoji: "🎸", symbolName: "star.fill") + let resolver = RegionStyleResolver(appearances: [.california: look]) + + let california = resolver.style(for: .california) + #expect(california.emoji == "🎸") + #expect(california.symbolName == "star.fill") + #expect(california.tint == RegionColorToken.pink.color) + } + + @Test func unpickedRegionFallsBackToDefault() { + let resolver = RegionStyleResolver(appearances: [:]) + let expected = RegionStyle.fallbackStyle(for: .newYork) + let resolved = resolver.style(for: .newYork) + #expect(resolved.emoji == expected.emoji) + #expect(resolved.symbolName == expected.symbolName) + } + + @Test func buildsFromPrimaryRegionsKeepingOnlyResolvedLooks() { + let look = RegionAppearance(color: .teal, emoji: "🌊", symbolName: "water.waves") + let resolver = RegionStyleResolver(primaryRegions: [ + PrimaryRegion(region: .california, appearance: look, order: 0), + PrimaryRegion(region: .newYork, appearance: nil, order: 1), + ]) + + #expect(resolver.style(for: .california).emoji == "🌊") + // NY had no appearance → resolves to its fallback, not California's look. + #expect(resolver.style(for: .newYork).emoji == RegionStyle.fallbackStyle(for: .newYork) + .emoji) + } + + @Test func defaultResolverIsAllFallback() { + let resolver = RegionStyleResolver.default + let expected = RegionStyle.fallbackStyle(for: .california) + #expect(resolver.style(for: .california).symbolName == expected.symbolName) + } + + @Test func defaultAppearanceMatchesSharedCatalog() { + // The customization pre-fill and the fallback share one source, so a + // region with no pick renders its catalog default. + let expected = RegionAppearanceCatalog.defaultAppearance(for: .california) + let fallback = RegionStyle.fallbackStyle(for: .california) + #expect(fallback.emoji == expected.emoji) + #expect(fallback.symbolName == expected.symbolName) + } +} diff --git a/Where/WhereWidgets/Sources/TodayWidget.swift b/Where/WhereWidgets/Sources/TodayWidget.swift index 2d768715..e1af9dd3 100644 --- a/Where/WhereWidgets/Sources/TodayWidget.swift +++ b/Where/WhereWidgets/Sources/TodayWidget.swift @@ -15,8 +15,11 @@ struct TodayWidget: Widget { // Seed the Broadway context so the shared WhereUI content views // resolve trait-aware `@Environment(\.stylesheet)` tokens instead // of falling back to `WhereStylesheet.default` (the extension has - // no other Broadway root). - .whereBroadwayRoot() + // no other Broadway root), plus the region looks the snapshot + // carries so `\.regionStyles` renders the user's picks. + .whereBroadwayRoot( + regionStyles: RegionStyleResolver(appearances: entry.snapshot.appearances), + ) } .configurationDisplayName(WidgetStrings.todayGalleryName) .description(WidgetStrings.todayGalleryDescription) diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index 7ebb731d..cb61a610 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -1,6 +1,5 @@ import LogKit import WhereCore -import WhereUI import WidgetKit struct WhereWidgetEntry: TimelineEntry { @@ -51,11 +50,6 @@ struct WhereWidgetProvider: TimelineProvider { do { let store = try WidgetSnapshotStore.shared() if let snapshot = store.read() { - // The widget process has no `WhereSession` to seed the styling - // registry from the store, so feed it the picked appearances the - // snapshot carries — this is what makes `region.style` render the - // user's chosen color/emoji/icon in the widget. - RegionStyleRegistry.shared.replaceAll(snapshot.appearances) return WhereWidgetEntry(date: now, snapshot: snapshot) } Self.logger.warning("No published widget snapshot; rendering empty state") diff --git a/Where/WhereWidgets/Sources/YearTotalsWidget.swift b/Where/WhereWidgets/Sources/YearTotalsWidget.swift index 361c0339..d4249655 100644 --- a/Where/WhereWidgets/Sources/YearTotalsWidget.swift +++ b/Where/WhereWidgets/Sources/YearTotalsWidget.swift @@ -15,8 +15,11 @@ struct YearTotalsWidget: Widget { // Seed the Broadway context so the shared WhereUI content views // resolve trait-aware `@Environment(\.stylesheet)` tokens instead // of falling back to `WhereStylesheet.default` (the extension has - // no other Broadway root). - .whereBroadwayRoot() + // no other Broadway root), plus the region looks the snapshot + // carries so `\.regionStyles` renders the user's picks. + .whereBroadwayRoot( + regionStyles: RegionStyleResolver(appearances: entry.snapshot.appearances), + ) } .configurationDisplayName(WidgetStrings.yearTotalsGalleryName) .description(WidgetStrings.yearTotalsGalleryDescription) From f7363b2250a6290a283641c76a1060859739cf31 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 21:03:08 -0700 Subject: [PATCH 06/14] Restore .other's original catch-all symbol Address code-review finding #5: unifying the bespoke look table into RegionAppearanceCatalog.defaultAppearance changed the user-visible .other glyph. Restore location.magnifyingglass (the fallback default isn't constrained to the picker's symbol catalog). EU/Canada looks are left as-is since those regions are being retired (not pickable). --- Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift index 269264b5..57a94d29 100644 --- a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift +++ b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift @@ -115,8 +115,11 @@ public enum RegionAppearanceCatalog { RegionAppearance(color: .red, emoji: "🍁", symbolName: "leaf.fill"), Region.europeanUnion.rawValue: RegionAppearance(color: .blue, emoji: "🇪🇺", symbolName: "star.fill"), + // `.other` keeps its original catch-all look; `location.magnifyingglass` + // isn't in the pickable `symbols` catalog, but a fallback default isn't + // constrained to the picker's options. Region.other.rawValue: - RegionAppearance(color: .teal, emoji: "🧭", symbolName: "mappin.circle.fill"), + RegionAppearance(color: .teal, emoji: "🧭", symbolName: "location.magnifyingglass"), ] /// A stable accent token derived from the region id, matching the order of From 790e8621c49281e98ed46b00cfa196bfffa82110 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 21:07:00 -0700 Subject: [PATCH 07/14] Signal a capped tap on the region picker map Address code-review finding #6: the List disables unselected rows at the 5-region cap, but the map silently ignored a tap on a new state. Now a capped map tap fires a warning haptic (.sensoryFeedback), and an at-capacity hint appears under the selection count in both modes, so the map is as responsive/legible as the list. --- .../Sources/Regions/RegionPickerView.swift | 42 ++++++++++++++----- Where/WhereUI/Sources/Shared/Strings.swift | 10 +++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift index 9d9532cf..4069505d 100644 --- a/Where/WhereUI/Sources/Regions/RegionPickerView.swift +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -19,6 +19,9 @@ struct RegionPickerView: View { @State private var mode: Mode = .map @State private var searchText = "" + /// Bumped whenever a map tap is ignored because the selection is full, to + /// drive the warning haptic. + @State private var capacityBumps = 0 /// The loaded map geometry + a matching attributor for tap hit-testing. One /// `Result` so "loading" (`nil`), success, and failure can't be confused. @State private var mapData: Result? @@ -32,13 +35,25 @@ struct RegionPickerView: View { VStack(spacing: stylesheet.spacing.medium) { modePicker - Text(Strings.regionPickerSelectionCount( - selected: model.selectionCount, - max: PrimaryRegionSelectionModel.maxSelection, - )) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - .accessibilityAddTraits(.updatesFrequently) + VStack(spacing: stylesheet.spacing.xSmall) { + Text(Strings.regionPickerSelectionCount( + selected: model.selectionCount, + max: PrimaryRegionSelectionModel.maxSelection, + )) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.updatesFrequently) + + if model.isAtCapacity { + Text(Strings + .regionPickerAtCapacity(max: PrimaryRegionSelectionModel.maxSelection)) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .transition(.opacity) + } + } + .animation(stylesheet.motion.captionFade, value: model.isAtCapacity) switch mode { case .map: @@ -47,6 +62,9 @@ struct RegionPickerView: View { listContent } } + // A capped tap on the map is otherwise silent (unlike the list, which + // disables rows), so signal it with a warning haptic. + .sensoryFeedback(.warning, trigger: capacityBumps) .task { if mapData == nil { await loadMap() } } @@ -118,9 +136,13 @@ struct RegionPickerView: View { private func handleMapTap(at coordinate: CLLocationCoordinate2D, in data: MapData) { let region = data.attributor.region(at: Coordinate(coordinate)) guard region != .other, model.available.contains(region) else { return } - // Ignore an add that would exceed the cap so a tap can't silently fail - // to register while still looking tappable. - guard model.canToggle(region) else { return } + guard model.canToggle(region) else { + // At capacity and tapping a new region — the list disables its rows + // to show this, but the map has no such affordance, so buzz instead + // of silently ignoring the tap. + capacityBumps += 1 + return + } model.toggle(region) } diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 6472e75d..e4c008cb 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -408,6 +408,16 @@ enum Strings { ) } + /// Shown when the selection is full, so an ignored tap on a new region reads + /// as "at capacity" rather than "unresponsive". + static func regionPickerAtCapacity(max: Int) -> String { + String( + localized: "regionPicker.atCapacity", + defaultValue: "That's the maximum of \(max) — deselect one to choose another.", + bundle: .module, + ) + } + static var regionPickerMapAccessibility: String { String( localized: "regionPicker.map.accessibility", From 8c516295c0b4f89f5dab8cba067c00422ee4ff16 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 21:14:46 -0700 Subject: [PATCH 08/14] Default the region picker to List and load map geometry lazily Address code-review finding #7: the picker parsed all ~52 US-region GeoJSON files on appear to back Map mode. Default to List (no geometry needed) and only run the parse when Map mode is first shown (.task(id: mode)), so a List-only user never pays the cost. --- Where/WhereUI/Sources/Regions/RegionPickerView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift index 4069505d..74015942 100644 --- a/Where/WhereUI/Sources/Regions/RegionPickerView.swift +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -17,7 +17,7 @@ struct RegionPickerView: View { case list } - @State private var mode: Mode = .map + @State private var mode: Mode = .list @State private var searchText = "" /// Bumped whenever a map tap is ignored because the selection is full, to /// drive the warning haptic. @@ -65,8 +65,11 @@ struct RegionPickerView: View { // A capped tap on the map is otherwise silent (unlike the list, which // disables rows), so signal it with a warning haptic. .sensoryFeedback(.warning, trigger: capacityBumps) - .task { - if mapData == nil { await loadMap() } + // Parse region geometry lazily — only once Map mode is actually shown, + // so a user who stays in the default List never pays the ~52-file parse. + .task(id: mode) { + guard mode == .map, mapData == nil else { return } + await loadMap() } } From 9c5108da4c975eea8cd1fe7a6de0c92ece2254aa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 15 Jul 2026 21:17:39 -0700 Subject: [PATCH 09/14] Clamp the region customize step index Address code-review finding #8: harden RegionCustomizeView so a stale/out-of-range step index can never strand the user on the empty state. Navigation and the step indicator now use a clamped effectiveIndex; the empty (nil) state is reserved for a genuinely empty selection, which the current flow never reaches. --- .../Sources/Regions/RegionCustomizeView.swift | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift index 00b3330b..5b1e322b 100644 --- a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -196,7 +196,7 @@ struct RegionCustomizeView: View { Button(Strings.onboardingBack, action: goBack) } ToolbarItem(placement: .principal) { - Text(Strings.regionCustomizeStep(current: index + 1, total: regions.count)) + Text(Strings.regionCustomizeStep(current: effectiveIndex + 1, total: regions.count)) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) } @@ -206,19 +206,27 @@ struct RegionCustomizeView: View { } } + /// `index` clamped into range, so a stale/out-of-range value can never + /// strand the user — the empty state (`currentRegion == nil`) is reserved for + /// a genuinely empty selection, which the current flow never reaches. + private var effectiveIndex: Int { + guard !regions.isEmpty else { return 0 } + return min(max(index, 0), regions.count - 1) + } + private var currentRegion: Region? { - regions.indices.contains(index) ? regions[index] : nil + regions.isEmpty ? nil : regions[effectiveIndex] } private var isLast: Bool { - index >= regions.count - 1 + effectiveIndex >= regions.count - 1 } private func goBack() { - if index == 0 { + if effectiveIndex == 0 { onBack() } else { - index -= 1 + index = effectiveIndex - 1 } } @@ -226,7 +234,7 @@ struct RegionCustomizeView: View { if isLast { onFinish() } else { - index += 1 + index = effectiveIndex + 1 } } } From 82cf179df09e4dcf45df1bf074cbdbf4d205374d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 16 Jul 2026 06:17:18 -0700 Subject: [PATCH 10/14] Fix WhereCoreTests build: rename primary() helper to pick() The test helper primary(_:_:_:) collided with the local 'let primary = try await store.primaryRegions()' variables. Local batch compilation resolved the call to the method, but CI's compilation mode resolved it to the [PrimaryRegion] local ('cannot call value of non-function type'). Rename the helper to pick() to remove the ambiguity. --- .../Tests/PrimaryRegionStoreTests.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift index 24004ba1..982a4f4d 100644 --- a/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift +++ b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift @@ -23,7 +23,7 @@ struct PrimaryRegionStoreTests { #expect(primary.map(\.order) == Array(0 ..< primary.count)) } - private func primary(_ region: Region, _ appearance: RegionAppearance?, _ order: Int) + private func pick(_ region: Region, _ appearance: RegionAppearance?, _ order: Int) -> PrimaryRegion { PrimaryRegion(region: region, appearance: appearance, order: order) @@ -36,8 +36,8 @@ struct PrimaryRegionStoreTests { let txLook = appearance(.red, "🤠", "star.fill") try await store.perform { try await store.setPrimaryRegions([ - primary(.california, caLook, 0), - primary(texas, txLook, 1), + pick(.california, caLook, 0), + pick(texas, txLook, 1), ]) } @@ -53,10 +53,10 @@ struct PrimaryRegionStoreTests { let first = appearance(.orange, "🌴", "sun.max.fill") let second = appearance(.indigo, "🌉", "building.2.fill") try await store.perform { - try await store.setPrimaryRegions([primary(.california, first, 0)]) + try await store.setPrimaryRegions([pick(.california, first, 0)]) } try await store.perform { - try await store.setPrimaryRegions([primary(.california, second, 3)]) + try await store.setPrimaryRegions([pick(.california, second, 3)]) } let primary = try await store.primaryRegions() #expect(primary.count == 1) @@ -69,14 +69,14 @@ struct PrimaryRegionStoreTests { let texas = try #require(Region(rawValue: "us-TX")) try await store.perform { try await store.setPrimaryRegions([ - primary(.california, appearance(.orange, "🌴", "sun.max.fill"), 0), - primary(texas, appearance(.red, "🤠", "star.fill"), 1), + pick(.california, appearance(.orange, "🌴", "sun.max.fill"), 0), + pick(texas, appearance(.red, "🤠", "star.fill"), 1), ]) } // Re-committing without California removes it by omission. try await store.perform { try await store.setPrimaryRegions([ - primary(texas, appearance(.red, "🤠", "star.fill"), 0), + pick(texas, appearance(.red, "🤠", "star.fill"), 0), ]) } #expect(try await store.primaryRegions().map(\.region) == [texas]) @@ -86,7 +86,7 @@ struct PrimaryRegionStoreTests { @Test func trackedWithoutAppearanceResolvesToNilLook() async throws { let store = try SwiftDataStore.inMemory() try await store.perform { - try await store.setPrimaryRegions([primary(.california, nil, 0)]) + try await store.setPrimaryRegions([pick(.california, nil, 0)]) } let primary = try await store.primaryRegions() #expect(primary.map(\.region) == [.california]) From 97412b037d078efa55ecf9bf7c11c99e334e3404 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 16 Jul 2026 07:37:03 -0700 Subject: [PATCH 11/14] Round-trip region appearance through backups Backups previously stored tracked regions as bare ids, so a restore brought back the regions but not their picked color/emoji/icon. Carry the full primary-region set (appearance + order) in the archive so a restore is lossless. - BackupArchive gains an additive primaryRegions: [PrimaryRegion] (decode-if-present), keeping the legacy trackedRegions ids alongside it for cross-version restores; resolvedPrimaryRegions falls back to those when primaryRegions is absent. PrimaryRegion is now Codable. - Export writes both; import restores via setPrimaryRegions for .replace and a merge (archive appearance wins on overlap) for .merge. - Tests: archive + end-to-end store round-trips, legacy fallback. - WhereCore AGENTS: add an invariant that persisted-model changes must be reflected in the backup end-to-end (archive + export + import + test). --- Where/WhereCore/AGENTS.md | 21 ++++-- .../Sources/Backup/BackupArchive.swift | 34 ++++++++-- .../Sources/Backup/BackupCoordinator.swift | 64 ++++++++++++++----- .../Sources/Backup/BackupService.swift | 2 + .../Sources/Regions/PrimaryRegion.swift | 2 +- .../Tests/BackupCoordinatorTests.swift | 19 ++++++ .../WhereCore/Tests/BackupServiceTests.swift | 47 ++++++++++++++ 7 files changed, 162 insertions(+), 27 deletions(-) diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 7a589b81..81cc341a 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -35,11 +35,22 @@ internal shape. pings `changes()` — the single signal readers refresh from. The live `ModelContainer` is surfaced only for the read-only debug inspector. - **Primary regions *are* the tracked-region set.** The picked primary regions - (`primaryRegions()` / `setPrimaryRegion(_:id:order:)`) are the same - `SDTrackedRegion` rows `trackedRegions()` reads — picking scopes GPS - attribution *and* carries each region's `RegionAppearance` (color token / - emoji / SF Symbol) + pick order. `RegionAppearance` is data (WhereCore); the - token→`Color` mapping and option catalogs are presentation (`WhereUI`). + (`primaryRegions()` / `setPrimaryRegions(_:)`) are the same `SDTrackedRegion` + rows `trackedRegions()` reads — picking scopes GPS attribution *and* carries + each region's `RegionAppearance` (color token / emoji / SF Symbol) + pick + order. `RegionAppearance` is data (WhereCore); the token→`Color` mapping and + option catalogs are presentation (`WhereUI`). +- **Backups mirror the persisted model — keep them lossless.** Any change to + persisted data (a new/changed `SD*` field, or a value type that crosses + `WhereStore`) must be reflected end-to-end in the backup so export/restore + never silently drops it: add it to `BackupArchive` (with a back-compatible + `init(from:)`), write it in `BackupService.makeArchiveFile`, read it back in + `BackupCoordinator.importBackup` for **both** `.replace` and `.merge`, and add + a round-trip test (`BackupServiceTests` for the archive, `BackupCoordinatorTests` + for the store round-trip). New archive fields are **additive** + (`decodeIfPresent` → default) and older keys stay put so a cross-version + restore still recovers what it can — see `primaryRegions` carried alongside the + legacy `trackedRegions` ids. - **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (year-month- day) is the timezone-independent identity of a day, and it is what every *stored user record* and *day comparison* keys on: `DayPresence.day`, diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index bb3c4bb6..b5da8b6c 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -34,10 +34,19 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// keeps issues the user already dismissed dismissed. Absent in manifests /// written before this field existed; those decode to `[]`. public let dismissedIssues: [DismissedIssue] - /// The user's tracked regions at export time, so a restore carries the - /// region selection like any other data. Absent in manifests written before - /// this field existed; those decode to `[]`. + /// The user's tracked regions at export time (region ids only), so a restore + /// carries the region selection like any other data. Absent in manifests + /// written before this field existed; those decode to `[]`. Retained + /// alongside ``primaryRegions`` for cross-version compatibility — an older + /// reader that predates `primaryRegions` still recovers the region set from + /// here (without the picked looks). public let trackedRegions: [Region] + /// The user's primary regions at export time, each with its picked + /// ``RegionAppearance`` (color / emoji / icon) and pick order, so a restore + /// brings back the *look*, not just the region set. Additive: absent in + /// manifests written before it existed (those decode to `[]`, and the + /// importer falls back to ``trackedRegions``). + public let primaryRegions: [PrimaryRegion] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -50,6 +59,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { manualDays: [DayPresence], dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], + primaryRegions: [PrimaryRegion] = [], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -59,9 +69,20 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.manualDays = manualDays self.dismissedIssues = dismissedIssues self.trackedRegions = trackedRegions + self.primaryRegions = primaryRegions self.assets = assets } + /// The primary regions to restore: ``primaryRegions`` when present, else a + /// fallback built from ``trackedRegions`` (older archives) with no picked + /// appearance and their listed order. + public var resolvedPrimaryRegions: [PrimaryRegion] { + if !primaryRegions.isEmpty { return primaryRegions } + return trackedRegions.enumerated().map { index, region in + PrimaryRegion(region: region, appearance: nil, order: index) + } + } + private enum CodingKeys: String, CodingKey { case formatVersion case exportedAt @@ -70,12 +91,13 @@ public struct BackupArchive: Codable, Sendable, Hashable { case manualDays case dismissedIssues case trackedRegions + case primaryRegions case assets } /// Custom decode (encode stays synthesized) so manifests written before - /// `dismissedIssues` / `trackedRegions` existed still import: the missing - /// keys decode to `[]` rather than throwing. + /// `dismissedIssues` / `trackedRegions` / `primaryRegions` existed still + /// import: the missing keys decode to `[]` rather than throwing. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) formatVersion = try container.decode(Int.self, forKey: .formatVersion) @@ -87,6 +109,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { .decodeIfPresent([DismissedIssue].self, forKey: .dismissedIssues) ?? [] trackedRegions = try container .decodeIfPresent([Region].self, forKey: .trackedRegions) ?? [] + primaryRegions = try container + .decodeIfPresent([PrimaryRegion].self, forKey: .primaryRegions) ?? [] assets = try container.decode([BackupAssetEntry].self, forKey: .assets) } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index fc2034be..eef1e71d 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -102,9 +102,11 @@ public actor BackupCoordinator { let evidence = try await store.allEvidence() let manualDays = try await store.allManualDays() let dismissedIssues = try await store.allDismissedIssues() - // The resolved tracked set (the four when the user hasn't chosen yet), - // in canonical order so the archive is stable. - let trackedRegions = try await Region.inCanonicalOrder(store.trackedRegions()) + // The user's primary regions with their picked looks + order (the + // resolved default set when they haven't chosen yet). `trackedRegions` + // carries the bare ids alongside it for older readers. + let primaryRegions = try await store.primaryRegions() + let trackedRegions = primaryRegions.map(\.region) var blobs: [UUID: Data] = [:] var lastPercent = -1 for (index, item) in evidence.enumerated() { @@ -125,6 +127,7 @@ public actor BackupCoordinator { manualDays: manualDays, dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, + primaryRegions: primaryRegions, blobs: blobs, ) }.value @@ -221,20 +224,21 @@ public actor BackupCoordinator { try await store.restoreDismissedIssue(dismissal) report() } - // Tracked regions round-trip like any other data. On `.replace` the - // store was cleared above, so write the archive's set exactly; on - // `.merge` union it into the current set (reading the *resolved* - // current set first so a device on the implicit default four doesn't - // collapse to just the imported regions). A handful of rows, so - // they're not folded into the progress total. - let regionsToWrite: Set = if strategy == .merge { - try await store.trackedRegions().union(archive.trackedRegions) + // Primary regions (with their picked looks) round-trip like any + // other data. On `.replace` the store was cleared above, so write + // the archive's set exactly; on `.merge` union it into the current + // set (reading the *resolved* current set first so a device on the + // implicit default four doesn't collapse to just the imported ones), + // with the archive's appearance winning on overlap. `setPrimaryRegions` + // is a whole-set replace, so a merge builds the full merged list. A + // handful of rows, so they're not folded into the progress total. + let archivePrimary = archive.resolvedPrimaryRegions + let regionsToWrite: [PrimaryRegion] = if strategy == .merge { + try await Self.merge(archivePrimary, into: store.primaryRegions()) } else { - Set(archive.trackedRegions) - } - for region in regionsToWrite { - try await store.setTrackedRegion(true, id: region.rawValue) + archivePrimary } + try await store.setPrimaryRegions(regionsToWrite) } // An import rewrites day data, so the badge / notification / widget // reconcile a day change runs has to follow it — these headless @@ -248,7 +252,35 @@ public actor BackupCoordinator { evidenceCount: archive.evidence.count, manualDayCount: archive.manualDays.count, dismissedIssueCount: archive.dismissedIssues.count, - trackedRegionCount: archive.trackedRegions.count, + trackedRegionCount: archive.resolvedPrimaryRegions.count, ) } + + /// Union `archive` primary regions into `current` for a `.merge` import: + /// current regions keep their order and come first, archive-only regions are + /// appended, and the archive's picked appearance wins on overlap (a `nil` + /// archive look never clobbers an existing customized one). Reindexed densely + /// for `setPrimaryRegions`. + private static func merge( + _ archive: [PrimaryRegion], + into current: [PrimaryRegion], + ) -> [PrimaryRegion] { + var appearances: [Region: RegionAppearance] = [:] + var order: [Region] = [] + var seen: Set = [] + func add(_ region: Region) { + if seen.insert(region).inserted { order.append(region) } + } + for entry in current { + add(entry.region) + if let appearance = entry.appearance { appearances[entry.region] = appearance } + } + for entry in archive { + add(entry.region) + if let appearance = entry.appearance { appearances[entry.region] = appearance } + } + return order.enumerated().map { index, region in + PrimaryRegion(region: region, appearance: appearances[region], order: index) + } + } } diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index c8bfcf5c..4224bc61 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -93,6 +93,7 @@ public struct BackupService: Sendable { manualDays: [DayPresence], dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], + primaryRegions: [PrimaryRegion] = [], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -124,6 +125,7 @@ public struct BackupService: Sendable { manualDays: manualDays, dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, + primaryRegions: primaryRegions, assets: assetEntries, ) let manifestData = try Self.makeEncoder().encode(archive) diff --git a/Where/WhereCore/Sources/Regions/PrimaryRegion.swift b/Where/WhereCore/Sources/Regions/PrimaryRegion.swift index baa1e2de..aec047e9 100644 --- a/Where/WhereCore/Sources/Regions/PrimaryRegion.swift +++ b/Where/WhereCore/Sources/Regions/PrimaryRegion.swift @@ -10,7 +10,7 @@ import RegionKit /// `appearance` is optional: a region can be tracked without a stored look yet /// (the out-of-the-box default set, or a legacy row), in which case the /// presentation layer falls back to a deterministic default style. -public struct PrimaryRegion: Hashable, Sendable { +public struct PrimaryRegion: Hashable, Sendable, Codable { public let region: Region public let appearance: RegionAppearance? public let order: Int diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 45c5e7ed..16b4664b 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -154,6 +154,25 @@ struct BackupCoordinatorTests { #expect(try await destination.store.trackedRegions() == [.california, texas]) } + @Test func importRestoresPickedRegionAppearance() async throws { + let source = try Self.makeHarness() + let caLook = RegionAppearance(color: .orange, emoji: "🌴", symbolName: "sun.max.fill") + try await source.store.perform { + try await source.store.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: caLook, order: 0), + ]) + } + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) + + let restored = try await destination.store.primaryRegions() + #expect(restored.map(\.region) == [.california]) + #expect(restored.first?.appearance == caLook) + } + @Test func mergeImportUnionsTrackedRegionsWithTheExisting() async throws { let source = try Self.makeHarness() let texas = try #require(Region(rawValue: "us-TX")) diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 8b0d8396..0c4c4448 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -166,6 +166,53 @@ struct BackupServiceTests { #expect(result.archive.trackedRegions == [.california, texas]) } + @Test func primaryRegionAppearanceSurvivesArchiveRoundTrip() throws { + let service = BackupService() + let texas = try #require(Region(rawValue: "us-TX")) + let primary = [ + PrimaryRegion( + region: .california, + appearance: RegionAppearance( + color: .orange, + emoji: "🌴", + symbolName: "sun.max.fill", + ), + order: 0, + ), + PrimaryRegion(region: texas, appearance: nil, order: 1), + ] + let url = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + trackedRegions: primary.map(\.region), + primaryRegions: primary, + blobs: [:], + exportedAt: Self.exportDate, + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try service.readArchive(at: url) + #expect(result.archive.primaryRegions == primary) + // The bare-id list is still written for older readers. + #expect(result.archive.trackedRegions == [.california, texas]) + } + + @Test func legacyManifestWithoutPrimaryRegionsFallsBackToTrackedRegions() throws { + // A manifest predating `primaryRegions` recovers the region set from + // `trackedRegions`, with no picked appearance. + let json = #""" + {"formatVersion":2,"exportedAt":"2026-06-05T12:00:00Z","samples":[],"evidence":[],"manualDays":[],"trackedRegions":["us-CA"],"assets":[]} + """# + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let archive = try decoder.decode(BackupArchive.self, from: Data(json.utf8)) + #expect(archive.primaryRegions.isEmpty) + #expect(archive.resolvedPrimaryRegions == [ + PrimaryRegion(region: .california, appearance: nil, order: 0), + ]) + } + @Test func legacyManifestWithoutTrackedRegionsDecodesAsEmpty() throws { // A manifest written before `trackedRegions` existed must decode to [] // rather than failing (additive field, like `dismissedIssues`). From f2228a98408fdcaabce44ac979865ad84a30e15b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 16 Jul 2026 07:41:56 -0700 Subject: [PATCH 12/14] Add restore-from-backup to onboarding Offer 'Restore from a backup' on the onboarding intro so a returning user can skip the manual region setup: it imports the chosen backup (.replace, since it's a fresh install) and jumps straight to the location ask. The location finale skips committing the manual selection when a restore ran, so it doesn't wipe the regions the restore wrote. Failures surface an alert and keep the user in onboarding. --- Where/WhereUI/README.md | 4 +- .../Sources/Onboarding/OnboardingView.swift | 104 +++++++++++++++--- Where/WhereUI/Sources/Shared/Strings.swift | 20 ++++ 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index e06e1c5f..94d53e3a 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -42,7 +42,9 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's - **`OnboardingView`** — the first-run flow, driven by a `LifecycleStepUIBridge`: a paged intro, then picking up to five primary US regions (map or searchable list) and giving each a look, then the location-permission ask. It commits the - picks as the tracked-region set + appearances before finishing. + picks as the tracked-region set + appearances before finishing. The intro also + offers **Restore from a backup**, which imports a backup (`.replace`) and skips + the manual pick/customize steps straight to the location ask. - **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region picker (segmented map/list) and per-region color/emoji/icon customization, backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 1254b4fe..ba9d0444 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -1,5 +1,6 @@ import LifecycleKit import SwiftUI +import UniformTypeIdentifiers import WhereCore /// First-run onboarding, run as one interactive launch step. A short paged @@ -38,6 +39,16 @@ public struct OnboardingView: View { @State private var selection = PrimaryRegionSelectionModel() @State private var isFinishing = false + // Restore-from-backup: the escape hatch that skips manual region setup. + @State private var showImporter = false + @State private var isRestoring = false + @State private var showRestoreError = false + @State private var restoreError: String? + /// Set once a backup restore succeeds, so the location-finale `finish` skips + /// committing the (empty) manual selection — which would otherwise wipe the + /// regions the restore just wrote. + @State private var didRestoreBackup = false + private static let logger = WhereLog.channel(.model) public init(bridge: LifecycleStepUIBridge) { @@ -87,6 +98,20 @@ public struct OnboardingView: View { .padding(.horizontal, stylesheet.spacing.xxxLarge) .padding(.bottom, stylesheet.spacing.xxxLarge) } + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.zip], + onCompletion: handleRestoreSelection, + ) + .alert( + Strings.onboardingRestoreErrorTitle, + isPresented: $showRestoreError, + presenting: restoreError, + ) { _ in + Button(Strings.commonOK, role: .cancel) {} + } message: { message in + Text(message) + } } private func pageView(_ page: OnboardingPage) -> some View { @@ -110,18 +135,29 @@ public struct OnboardingView: View { } private var introFooter: some View { - Button { - if page < pages.count - 1 { - withAnimation { page += 1 } + VStack(spacing: stylesheet.spacing.medium) { + Button { + if page < pages.count - 1 { + withAnimation { page += 1 } + } else { + phase = .pickRegions + } + } label: { + Text(Strings.onboardingContinue) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + // Returning users can skip the manual setup by restoring a backup. + if isRestoring { + ProgressView(Strings.onboardingRestoring) } else { - phase = .pickRegions + Button(Strings.onboardingRestoreBackup) { showImporter = true } + .controlSize(.large) } - } label: { - Text(Strings.onboardingContinue) - .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) - .controlSize(.large) + .disabled(isRestoring) } // MARK: - Pick regions @@ -204,17 +240,55 @@ public struct OnboardingView: View { if enableLocation { await session.startTracking() } - do { - try await selection.commit(using: session) - } catch { - // Don't strand the user in onboarding on a write failure — log - // it and continue; they can re-pick in Settings. - Self.logger.warning("Failed to commit onboarding region picks") + // A restore already wrote the primary regions; committing the (empty) + // manual selection here would wipe them, so only commit the picks + // when the user came through the manual flow. + if !didRestoreBackup { + do { + try await selection.commit(using: session) + } catch { + // Don't strand the user in onboarding on a write failure — + // log it and continue; they can re-pick in Settings. + Self.logger.warning("Failed to commit onboarding region picks") + } } model.completeOnboarding() bridge.complete() } } + + // MARK: - Restore from backup + + private func handleRestoreSelection(_ result: Result) { + switch result { + case let .success(url): + restore(from: url) + case let .failure(error): + restoreError = error.localizedDescription + showRestoreError = true + } + } + + /// Import the chosen backup (a fresh install, so `.replace` mirrors the file + /// exactly), then skip the manual pick/customize steps straight to the + /// location ask. On failure, surface an alert and stay in the intro. + private func restore(from url: URL) { + guard !isRestoring else { return } + isRestoring = true + Task { + do { + _ = try await session.services.backup.importBackup(from: url, strategy: .replace) + didRestoreBackup = true + isRestoring = false + phase = .location + } catch { + isRestoring = false + restoreError = error.localizedDescription + showRestoreError = true + Self.logger.warning("Onboarding backup restore failed") + } + } + } } /// One page of the onboarding intro. A plain value (not a view) so the page diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index e4c008cb..92dc813d 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -340,6 +340,26 @@ enum Strings { String(localized: "onboarding.next", defaultValue: "Next", bundle: .module) } + static var onboardingRestoreBackup: String { + String( + localized: "onboarding.restoreBackup", + defaultValue: "Restore from a backup", + bundle: .module, + ) + } + + static var onboardingRestoreErrorTitle: String { + String( + localized: "onboarding.restoreError.title", + defaultValue: "Couldn't restore backup", + bundle: .module, + ) + } + + static var onboardingRestoring: String { + String(localized: "onboarding.restoring", defaultValue: "Restoring…", bundle: .module) + } + static var onboardingRegionsTitle: String { String( localized: "onboarding.regions.title", From 24d3da98dcb6ee6528696ea3e25375b03f8f906a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 16 Jul 2026 07:50:18 -0700 Subject: [PATCH 13/14] AGENTS: require keeping the PR description current Add a root AGENTS rule that a PR's title/description must be updated in the same turn you push whenever the PR changes beyond a small bug fix (new behavior/API/data model, migration, scope change, follow-up, or approach-altering review fixes), reflect the end state, preserve human edits, and use gh pr edit when the PR tooling can't target the repo. --- AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 84b4604d..5b286369 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,6 +328,25 @@ work end to end without re-asking per comment. not addressed, record it somewhere durable (the module's `TODOs.md` or the review-tracking file) and reply linking where it's tracked. +## Keeping the PR description current + +A PR's title and description are the durable record of what it does — keep them +matching the branch, not just the first commit. + +- **When the PR changes beyond a small bug fix, update the description in the + same turn you push.** New behavior, a new/changed public API or data model, a + migration, a scope change, a follow-up feature, or review fixes that alter the + approach all warrant refreshing the body (and the title if the scope shifted). + A trivial fix — a typo, a one-line bug fix, a test tweak that doesn't change + what the PR is — doesn't. +- **Reflect the end state, not a changelog of the conversation.** Describe what + the PR now does; note notable decisions/trade-offs and testing. Don't leave a + stale body that only describes the initial commit. +- **Preserve human edits.** If someone edited the title/description in the + GitHub UI, fold your update into theirs rather than overwriting — only correct + what's now inaccurate. +- Use the PR tooling when it works; otherwise `gh pr edit --body-file`. + ## Debugging build/test/CI failures **Check `git status` and recent history first — before analyzing the error.** A From a0600121cf49d7807e07f3227fe8b30e67009fb4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 16 Jul 2026 15:21:27 -0700 Subject: [PATCH 14/14] Group the Log-a-day region toggles into tracked / used / everything else The manual-day form listed the full catalog flat. Now it groups the toggles: the user's tracked regions first, then any non-tracked regions the day already uses, then everything else in a collapsed disclosure. - RegionSelectionState gains applyTracked(_:) plus trackedItems / usedItems / otherItems; grouping keys off the initial selection + the tracked set so a row never jumps sections as it's toggled. Until the tracked set loads it stays nil and the form renders the flat list (also the preview/no-services fallback). - ManualDayView loads primaryRegions() once (.task) and renders the three sections; the collapsed 'More regions' disclosure holds the rest. - Tests cover the partition, tracked-over-used precedence, and toggle stability. --- .../Sources/Manual/ManualDayView.swift | 66 +++++++++++++++-- .../Sources/Shared/RegionSelectionState.swift | 56 +++++++++++++- Where/WhereUI/Sources/Shared/Strings.swift | 24 ++++++ .../Tests/RegionSelectionStateTests.swift | 74 +++++++++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 Where/WhereUI/Tests/RegionSelectionStateTests.swift diff --git a/Where/WhereUI/Sources/Manual/ManualDayView.swift b/Where/WhereUI/Sources/Manual/ManualDayView.swift index 5d922230..a443f75d 100644 --- a/Where/WhereUI/Sources/Manual/ManualDayView.swift +++ b/Where/WhereUI/Sources/Manual/ManualDayView.swift @@ -42,6 +42,10 @@ struct ManualDayView: View { @State private var deleteError = SaveErrorAlertState() @State private var showDeleteConfirmation = false @State private var pending: PendingWrite? + /// Whether the collapsed "more regions" group is expanded. + @State private var showAllRegions = false + + private static let logger = WhereLog.channel(.model) init(report: YearReportModel, mode: Mode, showsCancelButton: Bool = false) { self.report = report @@ -60,6 +64,7 @@ struct ManualDayView: View { } } .animation(.default, value: pending) + .task { await loadTrackedRegions() } .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -198,15 +203,62 @@ struct ManualDayView: View { // MARK: - Shared sections + @ViewBuilder private func regionsSection(_ regions: RegionSelectionState) -> some View { - Section { - ForEach(regions.items) { item in - RegionToggleRow(item: item) + if regions.trackedRegions == nil { + // Tracked set not loaded yet (or a preview without services): show + // the flat catalog list, matching the pre-grouping behavior. + Section { + ForEach(regions.items) { RegionToggleRow(item: $0) } + } header: { + Text(Strings.manualRegionsHeader) + } footer: { + Text(Strings.manualRegionsFooter) } - } header: { - Text(Strings.manualRegionsHeader) - } footer: { - Text(Strings.manualRegionsFooter) + } else { + if !regions.trackedItems.isEmpty { + Section { + ForEach(regions.trackedItems) { RegionToggleRow(item: $0) } + } header: { + Text(Strings.manualRegionsTrackedHeader) + } footer: { + Text(Strings.manualRegionsFooter) + } + } + if !regions.usedItems.isEmpty { + Section { + ForEach(regions.usedItems) { RegionToggleRow(item: $0) } + } header: { + Text(Strings.manualRegionsUsedHeader) + } + } + Section { + DisclosureGroup(isExpanded: $showAllRegions) { + ForEach(regions.otherItems) { RegionToggleRow(item: $0) } + } label: { + Text(Strings.manualRegionsMore) + } + } + } + } + + /// The region-selection state for the active mode. + private var activeRegions: RegionSelectionState { + switch fields { + case let .add(add): add.regions + case let .edit(edit): edit.regions + } + } + + /// Load the user's tracked/primary regions once so the region toggles can be + /// grouped (tracked / already-used / everything else). On failure the form + /// keeps the flat list rather than a broken grouping, and logs. + private func loadTrackedRegions() async { + guard activeRegions.trackedRegions == nil else { return } + do { + try await activeRegions.applyTracked(report.services.primaryRegions()) + } catch { + Self.logger.warning("Manual-day form couldn't load tracked regions for grouping") } } diff --git a/Where/WhereUI/Sources/Shared/RegionSelectionState.swift b/Where/WhereUI/Sources/Shared/RegionSelectionState.swift index e54beb7f..87a56ab3 100644 --- a/Where/WhereUI/Sources/Shared/RegionSelectionState.swift +++ b/Where/WhereUI/Sources/Shared/RegionSelectionState.swift @@ -21,14 +21,32 @@ final class RegionToggleItem: Identifiable { /// Bindable region toggles for manual-day and relabel forms. Each region is an /// `@Observable` row so `Toggle` can bind with `$item.isOn` instead of a /// closure-based `Binding`. +/// +/// The full catalog is a long list, so the forms group it: the user's **tracked** +/// regions first, then any **non-tracked regions this day already uses**, then +/// **everything else** (collapsed). Membership in those groups is stable while +/// the form is open — it keys off the *initial* selection and the tracked set +/// (loaded once via ``applyTracked(_:)``), so toggling a row never makes it jump +/// sections. Until the tracked set loads, ``trackedRegions`` is `nil` and callers +/// fall back to the flat ``items`` list. @Observable final class RegionSelectionState { var items: [RegionToggleItem] + /// The regions selected when the form opened, so the "already on this day" + /// group stays fixed as the user toggles. + private let initiallySelected: Set + + /// The user's tracked regions, loaded asynchronously by the form. `nil` + /// until loaded — callers render the flat `items` list meanwhile. + private(set) var trackedRegions: Set? + + /// Tracked regions in pick order, for the tracked group's row order. + private var trackedOrder: [Region] = [] + /// - Parameters: /// - regions: the regions to offer as toggles, in order. Defaults to every - /// available catalog region plus the `.other` catch-all; a caller (e.g. a - /// future onboarding-aware form) can pass the user's tracked subset. + /// available catalog region plus the `.other` catch-all. /// - selectedRegions: which of those start on. init( regions: [Region] = RegionCatalog.shared.all + [.other], @@ -37,11 +55,45 @@ final class RegionSelectionState { items = regions.map { RegionToggleItem(region: $0, isOn: selectedRegions.contains($0)) } + initiallySelected = selectedRegions } var selectedRegions: Set { Set(items.filter(\.isOn).map(\.region)) } + + /// Record the user's tracked/primary regions (in pick order) so the form can + /// group the toggles. Idempotent-friendly: the form loads once. + func applyTracked(_ primaryRegions: [PrimaryRegion]) { + trackedOrder = primaryRegions.map(\.region) + trackedRegions = Set(trackedOrder) + } + + /// The tracked regions' rows, in pick order. Empty until ``applyTracked(_:)``. + var trackedItems: [RegionToggleItem] { + guard trackedRegions != nil else { return [] } + let byRegion = Dictionary( + items.map { ($0.region, $0) }, + uniquingKeysWith: { first, _ in first }, + ) + return trackedOrder.compactMap { byRegion[$0] } + } + + /// Rows for regions this day already uses but the user doesn't track — the + /// initial selection minus the tracked set. Empty until loaded. + var usedItems: [RegionToggleItem] { + guard let tracked = trackedRegions else { return [] } + return items + .filter { initiallySelected.contains($0.region) && !tracked.contains($0.region) } + } + + /// Every remaining row — neither tracked nor already on this day. Falls back + /// to the full list until the tracked set loads. + var otherItems: [RegionToggleItem] { + guard let tracked = trackedRegions else { return items } + return items + .filter { !tracked.contains($0.region) && !initiallySelected.contains($0.region) } + } } /// Drives save-error alerts on manual-day forms. The message is the source of diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 92dc813d..714e3efd 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -1125,6 +1125,30 @@ enum Strings { localized("manual.regions.footer") } + static var manualRegionsTrackedHeader: String { + String( + localized: "manual.regions.tracked.header", + defaultValue: "Your regions", + bundle: .module, + ) + } + + static var manualRegionsUsedHeader: String { + String( + localized: "manual.regions.used.header", + defaultValue: "Already on this day", + bundle: .module, + ) + } + + static var manualRegionsMore: String { + String( + localized: "manual.regions.more", + defaultValue: "More regions", + bundle: .module, + ) + } + static var manualTitle: String { localized("manual.title") } diff --git a/Where/WhereUI/Tests/RegionSelectionStateTests.swift b/Where/WhereUI/Tests/RegionSelectionStateTests.swift new file mode 100644 index 00000000..1275acd2 --- /dev/null +++ b/Where/WhereUI/Tests/RegionSelectionStateTests.swift @@ -0,0 +1,74 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// The manual-day / relabel region toggles group into tracked / already-used / +/// everything-else once the tracked set loads, and membership stays stable as +/// rows are toggled. +struct RegionSelectionStateTests { + private func region(_ id: String) throws -> Region { + try #require(Region(rawValue: id)) + } + + private func primary(_ regions: [Region]) -> [PrimaryRegion] { + regions.enumerated().map { PrimaryRegion(region: $1, appearance: nil, order: $0) } + } + + @Test func flatUntilTrackedLoads() throws { + let texas = try region("us-TX") + let state = RegionSelectionState( + regions: [.california, .newYork, texas, .other], + selectedRegions: [.newYork], + ) + // Before the tracked set loads, callers render the flat list. + #expect(state.trackedRegions == nil) + #expect(state.otherItems.map(\.region) == [.california, .newYork, texas, .other]) + #expect(state.trackedItems.isEmpty) + #expect(state.usedItems.isEmpty) + } + + @Test func partitionsIntoTrackedUsedAndEverythingElse() throws { + let texas = try region("us-TX") + let state = RegionSelectionState( + regions: [.california, .newYork, texas, .other], + selectedRegions: [.newYork, .other], + ) + state.applyTracked(primary([.california, .newYork])) + + // Tracked first, in pick order. + #expect(state.trackedItems.map(\.region) == [.california, .newYork]) + // Non-tracked but already on this day. + #expect(state.usedItems.map(\.region) == [.other]) + // Everything else. + #expect(state.otherItems.map(\.region) == [texas]) + } + + @Test func trackedTakesPrecedenceOverUsed() { + let state = RegionSelectionState( + regions: [.california, .newYork, .other], + selectedRegions: [.california, .other], + ) + state.applyTracked(primary([.california])) + // California is tracked *and* initially selected → tracked, not used. + #expect(state.trackedItems.map(\.region) == [.california]) + #expect(state.usedItems.map(\.region) == [.other]) + } + + @Test func togglingDoesNotChangeSectionMembership() throws { + let texas = try region("us-TX") + let state = RegionSelectionState( + regions: [.california, texas, .other], + selectedRegions: [], + ) + state.applyTracked(primary([.california])) + #expect(state.otherItems.map(\.region) == [texas, .other]) + + // Turn on a row in "everything else" — it stays there, and the overall + // selection reflects it. + let texasItem = try #require(state.otherItems.first { $0.region == texas }) + texasItem.isOn = true + #expect(state.otherItems.map(\.region) == [texas, .other]) + #expect(state.selectedRegions == [texas]) + } +}